feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: portals
---
Movies that exist in several of your playlists now show a "Sources" chip on the
detail page. Switch playlist mid-film and playback continues from the same
timecode, pin a preferred source per movie, and when a stream dies the error
screen offers the alternatives instead of a dead end. Optional auto-switching is
off by default.
+14 -1
View File
@@ -252,7 +252,7 @@ This is an Nx monorepo with the following structure:
- **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components
- **portal/catalog/feature** - Portal catalog UI
- **portal/downloads/feature** - Download manager UI
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code
- **portal/shared/{data-access,ui,util}** - Cross-portal shared code (incl. the VOD multi-source discovery/resolve/ranking layer in `data-access/src/lib/multi-source/`)
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
@@ -598,6 +598,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `downloads` - Download manager state
- `appState` - Key-value app state (also tracks one-off data migrations)
- `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)
- `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`)
- **Connection**: `libs/shared/database/src/lib/connection.ts`
- `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)
- Provides full read-write access for `electron-backend` and a read-only mode
@@ -817,6 +818,18 @@ engine` (restart required) or
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**VOD Multi-Source** (alternative sources for a movie):
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored `MatMenu` popover (`libs/ui/components/src/lib/vod-sources/`), reused unchanged on the playback-error screen.
- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup). No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
**Radio Player**:
- Dedicated audio player for channels with `radio="true"` M3U attribute
@@ -0,0 +1,131 @@
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
/**
* `multisrc1` and `multisrc2` are two distinct credential pairs that share one
* faker seed, so both portals generate an IDENTICAL catalog. That overlap is
* the whole premise of this feature: the same movie in two playlists.
*/
const portalA = { username: 'multisrc1', password: 'multisrc1' };
const portalB = { username: 'multisrc2', password: 'multisrc2' };
test.describe('VOD multi-source', () => {
test('offers the same movie from another playlist and switches to it', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, portalA);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getXtreamTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...portalA,
name: 'Multi Source A',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
// The second portal is what makes an alternative exist at all.
await addXtreamPortal(app.mainWindow, {
...portalB,
name: 'Multi Source B',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// Discovery is lazy, so the chip appears after the detail view.
const chip = app.mainWindow.locator('app-vod-sources-chip button');
await expect(chip).toBeVisible({ timeout: 20000 });
// Exactly one alternative: the same film in the other playlist.
await expect(chip).toContainText('1');
await chip.click();
// Both the playing source and the alternative are listed.
const rows = app.mainWindow.locator('app-vod-source-row');
await expect(rows).toHaveCount(2);
// Portal B was added last and is active, so A is the alternative.
await expect(
rows.filter({ hasText: 'Multi Source A' })
).toHaveCount(1);
// The playing row must name its playlist, never a raw UUID.
await expect(
rows.filter({ hasText: 'Multi Source B' })
).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('shows no sources chip when only one playlist has the movie', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, portalA);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getXtreamTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...portalA,
name: 'Multi Source A',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
// The action row must look exactly as it did before this feature.
await expect(
app.mainWindow.locator('.play-btn').first()
).toBeVisible({ timeout: 20000 });
await expect(
app.mainWindow.locator('app-vod-sources-chip')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
@@ -50,6 +50,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const dbPreloadCases: PreloadInvokeCase[] = [
{
@@ -388,6 +400,30 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_MATCH_TITLES',
forwardedArgs: [['The Matrix', 'Inception']],
},
{
method: 'dbFindTitleSources',
args: [vodSourceRequest],
channel: 'DB_FIND_TITLE_SOURCES',
forwardedArgs: [vodSourceRequest],
},
{
method: 'dbGetVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_GET_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
{
method: 'dbSetVodSourcePin',
args: [vodSourcePin],
channel: 'DB_SET_VOD_SOURCE_PIN',
forwardedArgs: [vodSourcePin],
},
{
method: 'dbClearVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_CLEAR_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
];
export const epgPreloadCases: PreloadInvokeCase[] = [
@@ -48,6 +48,7 @@ import type {
Settings,
TmdbCacheEntry,
TmdbCacheMediaType,
VodSourcePin,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import {
@@ -609,6 +610,8 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
probeStreamUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('STREAM_PROBE_URL', { url, method }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
@@ -839,6 +842,18 @@ const electronApi: ElectronBridgeApi = {
dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'),
dbMatchTitles: (titles: string[]) =>
ipcRenderer.invoke('DB_MATCH_TITLES', titles),
// VOD multi-source
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request),
dbGetVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys),
dbSetVodSourcePin: (pin: VodSourcePin) =>
ipcRenderer.invoke('DB_SET_VOD_SOURCE_PIN', pin),
dbClearVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
// Playback Positions
dbSavePlaybackPosition: (
playlistId: string,
@@ -0,0 +1,193 @@
import type { AppDatabase } from '../database.types';
import { findTitleSources } from './title-sources.operations';
function createDbMock(rows: unknown[] = []) {
const all = jest.fn().mockResolvedValue(rows);
return { db: { all } as unknown as AppDatabase, all };
}
function createFailingDbMock() {
const all = jest.fn().mockRejectedValue(new Error('fts syntax'));
return { db: { all } as unknown as AppDatabase, all };
}
const duneRow = {
content_id: 1,
title: 'Dune',
xtream_id: 501,
poster_url: 'https://cdn.example.com/dune.jpg',
category_xtream_id: 12,
playlist_id: 'playlist-1',
playlist_name: 'Portal One',
};
describe('title-sources.operations', () => {
describe('unusable queries', () => {
it('returns nothing for a blank or non-string title', async () => {
const { db, all } = createDbMock([duneRow]);
await expect(findTitleSources(db, { title: '' })).resolves.toEqual(
[]
);
await expect(
findTitleSources(db, { title: ' ' })
).resolves.toEqual([]);
await expect(
findTitleSources(db, {
title: null as unknown as string,
})
).resolves.toEqual([]);
expect(all).not.toHaveBeenCalled();
});
it('returns nothing when no token survives the trigram minimum', async () => {
const { db, all } = createDbMock([duneRow]);
// "up" is two chars — the trigram tokenizer cannot match it, so
// there is no FTS query left to run.
await expect(
findTitleSources(db, { title: 'Up' })
).resolves.toEqual([]);
expect(all).not.toHaveBeenCalled();
});
it('returns nothing instead of throwing when the FTS query fails', async () => {
const { db, all } = createFailingDbMock();
await expect(
findTitleSources(db, { title: 'Weird "Title"' })
).resolves.toEqual([]);
expect(all).toHaveBeenCalledTimes(1);
});
});
describe('match confirmation', () => {
it('maps a confirmed row onto the candidate shape as an exact match', async () => {
const { db } = createDbMock([duneRow]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([
{
playlistId: 'playlist-1',
playlistName: 'Portal One',
categoryId: 12,
xtreamId: 501,
title: 'Dune',
posterUrl: 'https://cdn.example.com/dune.jpg',
matchConfidence: 'exact',
year: null,
},
]);
});
it('confirms a year-stripped candidate as a fuzzy match', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBe(1984);
});
it('drops candidates whose normalized title differs', async () => {
const { db } = createDbMock([
{ ...duneRow, title: 'Dune Part Two' },
]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([]);
});
it('rejects a base-tier match whose year contradicts the request', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
// Same base title, but the user is watching the 2021 film — a
// year-stripped match is only trustworthy when the two sides do
// not actively disagree.
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('keeps a base-tier match when one side has no known year', async () => {
const { db } = createDbMock([duneRow]);
// Query carries the year tag, the candidate does not: an unknown
// year must never block the match.
const matches = await findTitleSources(db, { title: 'Dune 2021' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBeNull();
});
it('tolerates a one-year drift between the two sides', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]);
const matches = await findTitleSources(db, {
title: 'Dune',
year: 2020,
});
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
});
});
describe('playlist scoping and duplicates', () => {
it('never returns the playlist the user is already on', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
content_id: 2,
xtream_id: 777,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
});
expect(matches).toHaveLength(1);
expect(matches[0].playlistId).toBe('playlist-2');
});
it('collapses the same film listed in several categories', async () => {
const { db } = createDbMock([
duneRow,
{ ...duneRow, content_id: 2, category_xtream_id: 13 },
{ ...duneRow, content_id: 3, category_xtream_id: 14 },
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].categoryId).toBe(12);
});
it('keeps the same film when it lives in different playlists', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches.map((match) => match.playlistId)).toEqual([
'playlist-1',
'playlist-2',
]);
});
});
});
@@ -0,0 +1,146 @@
import { sql } from 'drizzle-orm';
import {
normalizeTitleKeys,
titleYearsCompatible,
type VodSourceCandidateRow,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* VOD multi-source discovery: find the SAME movie in the user's other
* playlists.
*
* Deliberately not built on `buildTitleMatchIndex()` — that helper keeps one
* match per title key because the Similar rail only needs "does this exist
* somewhere". Here every copy in every playlist is a distinct, selectable
* source, so all of them are returned.
*
* Scope is Xtream-only: Stalker items never reach the `content` table (they
* would need a live authenticated portal search), and M3U playlists are stored
* as a JSON blob whose search path forces `content_type: 'live'`.
*/
const CANDIDATE_LIMIT = 60;
/** A row as it comes back from SQLite, before match confirmation. */
interface TitleSourceRow {
content_id: number;
title: string;
xtream_id: number;
poster_url: string | null;
category_xtream_id: number;
playlist_id: string;
playlist_name: string;
}
export interface FindTitleSourcesRequest {
title: string;
/** Known release year of the movie being viewed, when available. */
year?: number | null;
/** The playlist the user is already on — never returned as an alternative. */
excludePlaylistId?: string | null;
}
function buildFtsMatchQuery(normalizedTitle: string): string {
const tokens = normalizedTitle
.split(' ')
// The trigram tokenizer cannot match tokens shorter than 3 chars
.filter((token) => token.length >= 3)
.map((token) => `"${token.replace(/"/g, '""')}"`);
return tokens.join(' AND ');
}
export async function findTitleSources(
db: AppDatabase,
request: FindTitleSourcesRequest
): Promise<VodSourceCandidateRow[]> {
const rawTitle = request?.title;
if (typeof rawTitle !== 'string' || rawTitle.trim() === '') {
return [];
}
const wanted = normalizeTitleKeys(rawTitle);
// Search on the year-stripped form so a portal that tags the year and one
// that does not still find each other; the year gate below re-tightens it.
const matchQuery = buildFtsMatchQuery(wanted.base);
if (!wanted.base || !matchQuery) {
return [];
}
// The year the caller knows, falling back to a release tag on the title.
const wantedYear = request.year ?? wanted.trailingYear ?? null;
let rows: TitleSourceRow[];
try {
rows = (await db.all(sql`
SELECT
c.id AS content_id,
c.title AS title,
c.xtream_id AS xtream_id,
c.poster_url AS poster_url,
cat.xtream_id AS category_xtream_id,
cat.playlist_id AS playlist_id,
p.name AS playlist_name
FROM content_title_fts
INNER JOIN content AS c ON c.id = content_title_fts.rowid
INNER JOIN categories AS cat ON c.category_id = cat.id
INNER JOIN playlists AS p ON cat.playlist_id = p.id
WHERE content_title_fts MATCH ${matchQuery}
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
ORDER BY rank, c.title
LIMIT ${CANDIDATE_LIMIT}
`)) as TitleSourceRow[];
} catch {
// Malformed FTS query for exotic titles — no sources, not a crash
return [];
}
const matches: VodSourceCandidateRow[] = [];
// One playlist can list the same film in several categories; the user
// thinks of that as one source.
const seen = new Set<string>();
for (const row of rows) {
if (
request.excludePlaylistId &&
row.playlist_id === request.excludePlaylistId
) {
continue;
}
const rowKeys = normalizeTitleKeys(row.title);
const exactMatch =
rowKeys.exact !== '' && rowKeys.exact === wanted.exact;
const baseMatch =
!exactMatch &&
rowKeys.base === wanted.base &&
// A year-stripped match is only trustworthy when the two sides do
// not actively disagree about the year ("Dune" 1984 vs 2021).
titleYearsCompatible(rowKeys.trailingYear, wantedYear);
if (!exactMatch && !baseMatch) {
continue;
}
const dedupeKey = `${row.playlist_id}:${row.xtream_id}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
matches.push({
playlistId: row.playlist_id,
playlistName: row.playlist_name,
categoryId: row.category_xtream_id,
xtreamId: row.xtream_id,
title: row.title,
posterUrl: row.poster_url,
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
year: rowKeys.trailingYear,
});
}
return matches;
}
@@ -0,0 +1,210 @@
import {
createDbMock,
mockDrizzle,
mockDrizzleOrmModule,
resetDrizzleMocks,
} from './operations.test-helpers';
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import {
clearVodSourcePin,
getVodSourcePin,
setVodSourcePin,
} from './vod-source-pin.operations';
const tmdbRow = {
id: 1,
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream' as const,
updatedAt: '2026-07-20T10:00:00.000Z',
};
const titleRow = {
id: 2,
matchKey: 'title:the matrix:1999',
playlistId: 'playlist-2',
contentId: 902,
portalType: 'xtream' as const,
updatedAt: '2026-07-01T10:00:00.000Z',
};
/** `createDbMock` resolves `.values()` directly; pins upsert on top of it. */
function createUpsertDbMock() {
const mock = createDbMock();
const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined);
mock.insertValues.mockReturnValue({ onConflictDoUpdate });
return { ...mock, onConflictDoUpdate };
}
describe('vod-source-pin.operations', () => {
beforeEach(() => {
resetDrizzleMocks();
});
describe('getVodSourcePin', () => {
it('returns null without querying for an empty key list', async () => {
const { db, select } = createDbMock();
await expect(getVodSourcePin(db, [])).resolves.toBeNull();
expect(select).not.toHaveBeenCalled();
});
it('returns null when no pin row exists', async () => {
const { db } = createDbMock([[]]);
await expect(getVodSourcePin(db, ['tmdb:603'])).resolves.toBeNull();
});
it('honours caller key order, not the order SQLite returned', async () => {
// SQLite hands back the older title-keyed row first; the caller
// passes the tmdb key first because it is the more trusted one.
// Reading in row order would let a stale title pin shadow it.
const { db } = createDbMock([[titleRow, tmdbRow]]);
await expect(
getVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: '2026-07-20T10:00:00.000Z',
});
});
it('falls back to the next key when the first has no row', async () => {
const { db } = createDbMock([[titleRow]]);
const pin = await getVodSourcePin(db, [
'tmdb:603',
'title:the matrix:1999',
]);
expect(pin?.matchKey).toBe('title:the matrix:1999');
});
it('reports a missing timestamp as undefined', async () => {
const { db } = createDbMock([[{ ...tmdbRow, updatedAt: null }]]);
const pin = await getVodSourcePin(db, ['tmdb:603']);
expect(pin?.updatedAt).toBeUndefined();
});
it('drops junk keys and caps the lookup at eight keys', async () => {
const { db } = createDbMock([[]]);
const keys = [
'',
null as unknown as string,
undefined as unknown as string,
42 as unknown as string,
'k1',
'k2',
'k3',
'k4',
'k5',
'k6',
'k7',
'k8',
'k9',
];
await getVodSourcePin(db, keys);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['k1', 'k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8']
);
});
});
describe('setVodSourcePin', () => {
const pin: VodSourcePin = {
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
};
it('upserts on the match key', async () => {
const { db, insert, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await expect(setVodSourcePin(db, pin)).resolves.toEqual({
success: true,
});
expect(insert).toHaveBeenCalledWith(schema.vodSourcePins);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
})
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: schema.vodSourcePins.matchKey,
set: expect.objectContaining({
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
}),
})
);
});
it('writes an ISO timestamp on both the insert and the update', async () => {
const { db, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await setVodSourcePin(db, pin);
const inserted = insertValues.mock.calls[0][0] as {
updatedAt: string;
};
const updated = onConflictDoUpdate.mock.calls[0][0] as {
set: { updatedAt: string };
};
expect(new Date(inserted.updatedAt).toISOString()).toBe(
inserted.updatedAt
);
expect(updated.set.updatedAt).toBe(inserted.updatedAt);
});
});
describe('clearVodSourcePin', () => {
it('reports failure without touching the database for no keys', async () => {
const { db, deleteFn } = createDbMock();
await expect(clearVodSourcePin(db, [])).resolves.toEqual({
success: false,
});
expect(deleteFn).not.toHaveBeenCalled();
});
it('deletes every alias of the movie in one statement', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins);
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['tmdb:603', 'title:the matrix:1999']
);
});
});
});
@@ -0,0 +1,108 @@
import { inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* VOD multi-source pins — the user's per-movie choice of preferred playlist.
*
* Lookups take SEVERAL keys rather than one: a movie pinned before TMDB
* enrichment landed is stored under its title key, and the same movie prefers
* a `tmdb:` key afterwards. Reading both means the id arriving later does not
* orphan an existing pin.
*/
/** Guards against an unbounded IN list if a caller ever passes junk. */
const MAX_KEYS_PER_LOOKUP = 8;
export async function getVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<VodSourcePin | null> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return null;
}
const rows = await db
.select()
.from(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
if (rows.length === 0) {
return null;
}
// Callers pass keys most-trusted first (tmdb before title), so honour that
// order rather than whatever SQLite returned.
for (const key of keys) {
const row = rows.find((candidate) => candidate.matchKey === key);
if (row) {
return toPin(row);
}
}
return null;
}
export async function setVodSourcePin(
db: AppDatabase,
pin: VodSourcePin
): Promise<{ success: boolean }> {
const updatedAt = new Date().toISOString();
await db
.insert(schema.vodSourcePins)
.values({
matchKey: pin.matchKey,
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
})
.onConflictDoUpdate({
target: schema.vodSourcePins.matchKey,
set: {
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
},
});
return { success: true };
}
export async function clearVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<{ success: boolean }> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return { success: false };
}
// Clears every alias of the movie, so unpinning is not undone by a stale
// row under the other key form.
await db
.delete(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
return { success: true };
}
function toPin(row: schema.VodSourcePinRow): VodSourcePin {
return {
matchKey: row.matchKey,
playlistId: row.playlistId,
contentId: row.contentId,
portalType: row.portalType,
updatedAt: row.updatedAt ?? undefined,
};
}
@@ -14,6 +14,7 @@ import './database/playback-position.events';
import './database/playlist.events';
import './database/recently-viewed.events';
import './database/tmdb.events';
import './database/vod-sources.events';
import './database/xtream.events';
export default class DatabaseEvents {
@@ -0,0 +1,23 @@
/**
* VOD multi-source IPC event handlers
* Cross-playlist movie discovery plus the per-movie pinned source.
*/
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { FindTitleSourcesRequest } from '../../database/operations/title-sources.operations';
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_FIND_TITLE_SOURCES',
(request: FindTitleSourcesRequest) => ({ request })
);
handleWorkerRequest('DB_GET_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
handleWorkerRequest('DB_SET_VOD_SOURCE_PIN', (pin: VodSourcePin) => ({ pin }));
handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
@@ -31,6 +31,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const workerIpcContractCases: WorkerIpcContractCase[] = [
{
@@ -338,4 +350,24 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [['The Matrix', 'Inception']],
payload: { titles: ['The Matrix', 'Inception'] },
},
{
operation: 'DB_FIND_TITLE_SOURCES',
args: [vodSourceRequest],
payload: { request: vodSourceRequest },
},
{
operation: 'DB_GET_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
{
operation: 'DB_SET_VOD_SOURCE_PIN',
args: [vodSourcePin],
payload: { pin: vodSourcePin },
},
{
operation: 'DB_CLEAR_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
];
@@ -45,6 +45,7 @@ async function importDatabaseEventModules(): Promise<void> {
await import('./playlist.events');
await import('./recently-viewed.events');
await import('./tmdb.events');
await import('./vod-sources.events');
await import('./xtream.events');
}
@@ -0,0 +1,181 @@
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}
),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
describe('stream probe', () => {
beforeEach(async () => {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
const { registerStreamProbeHandlers } = await import('./stream-probe');
registerStreamProbeHandlers();
});
it('registers both the generic and the legacy Xtream channel', () => {
expect(registeredHandlers.get('STREAM_PROBE_URL')).toBeDefined();
// The catchup variant probe still calls the old channel.
expect(registeredHandlers.get('XTREAM_PROBE_URL')).toBeDefined();
});
it('defaults to HEAD and reports latency', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
expect(result.status).toBe(200);
expect(typeof result.latencyMs).toBe('number');
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
const request = axiosMock.mock.calls[0][0] as {
method?: string;
headers?: Record<string, string>;
responseType?: string;
};
expect(request.method).toBe('HEAD');
// No Range header on HEAD — that is only the GET fallback's job.
expect(request.headers?.['Range']).toBeUndefined();
expect(request.responseType).toBeUndefined();
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
// The ranged GET opens a stream nobody reads — it must be released.
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
// A policy rejection carries no latency: it never left the process,
// and callers must be able to tell it apart from a real timing.
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('reports a refusal status rather than treating it as unreachable', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const refusal = Object.assign(new Error('forbidden'), {
response: { status: 403 },
});
axiosMock.mockRejectedValueOnce(refusal);
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === refusal
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
// 403 answers "is anything there" — it is a fact, not a failure to check.
expect(result.status).toBe(403);
expect(typeof result.latencyMs).toBe('number');
});
it('returns status 0 without latency when no response is obtained', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockRejectedValueOnce(new Error('ETIMEDOUT'));
axiosMock.isAxiosError.mockReturnValue(false);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; url: string; latencyMs?: number };
expect(result).toEqual({
status: 0,
url: 'http://example.com/movie.mkv',
});
});
});
@@ -0,0 +1,123 @@
/**
* Stream reachability probe.
*
* A cheap "is this URL alive" check that runs in the main process because the
* renderer cannot do it: a browser request to an arbitrary IPTV host is
* CORS-blocked, and `no-cors` yields an opaque response where 200, 403 and 404
* are indistinguishable. Electron does not escape this either — `webSecurity`
* stays on — so the honest answer has to come from Node.
*
* Originally inline in `xtream.events.ts` for catchup URL checks; extracted so
* VOD multi-source can reuse it, and so that file stays inside its line budget.
*/
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
const PROBE_TIMEOUT_MS = 10000;
export interface StreamProbePayload {
url: string;
method?: 'GET' | 'HEAD';
}
export interface StreamProbeResponse {
/**
* The HTTP status, or 0 when no response was obtained at all.
*
* 0 is NOT "offline" — it also covers a timeout and a URL rejected by the
* redirect-safety policy. Callers must render it as "could not check",
* never as a dead source.
*/
status: number;
url: string;
/** Round-trip time; present whenever the request completed either way. */
latencyMs?: number;
error?: string;
}
export async function runStreamProbe(
payload: StreamProbePayload
): Promise<StreamProbeResponse> {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
'User-Agent': PROBE_CLIENT_USER_AGENT,
// Some servers reject a bare HEAD but answer a tiny ranged GET.
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: PROBE_TIMEOUT_MS,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
// Monotonic: a wall-clock change mid-probe must not yield a negative latency.
const startedAt = performance.now();
const elapsed = () => Math.round(performance.now() - startedAt);
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
// A ranged GET opens a stream we never read — release it immediately.
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
latencyMs: elapsed(),
};
} catch (error) {
// A refusal still answers the question "is anything there".
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
latencyMs: elapsed(),
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
export function registerStreamProbeHandlers(): void {
ipcMain.handle(
'STREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
// Retained for the Xtream catchup variant probe, which predates this
// module and whose renderer call site still uses the old channel.
ipcMain.handle(
'XTREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
}
@@ -125,85 +125,6 @@ describe('XtreamEvents session cancellation', () => {
expect(userAgent).not.toMatch(/^Mozilla\/5\.0 /);
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
@@ -12,7 +12,6 @@ import {
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
@@ -296,66 +295,3 @@ type ActiveXtreamRequest = {
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
ipcMain.handle(
'XTREAM_PROBE_URL',
async (
_event,
payload: {
url: string;
method?: 'GET' | 'HEAD';
}
) => {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
'User-Agent': XTREAM_CLIENT_USER_AGENT,
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: 10000,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
);
@@ -56,6 +56,10 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_TMDB_CACHE_STATS',
'DB_CLEAR_TMDB_METADATA',
'DB_MATCH_TITLES',
'DB_FIND_TITLE_SOURCES',
'DB_GET_VOD_SOURCE_PIN',
'DB_SET_VOD_SOURCE_PIN',
'DB_CLEAR_VOD_SOURCE_PIN',
] as const;
export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number];
@@ -4,6 +4,7 @@ import {
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
VodSourcePin,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces';
@@ -78,12 +79,21 @@ import {
removeRecentItemsBatch,
} from '../database/operations/recently-viewed.operations';
import { matchTitles } from '../database/operations/title-match.operations';
import {
findTitleSources,
type FindTitleSourcesRequest,
} from '../database/operations/title-sources.operations';
import {
clearTmdbMetadata,
getTmdbCacheStats,
getTmdbMetadata,
setTmdbMetadata,
} from '../database/operations/tmdb.operations';
import {
clearVodSourcePin,
getVodSourcePin,
setVodSourcePin,
} from '../database/operations/vod-source-pin.operations';
import {
deleteXtreamContent,
restoreXtreamUserData,
@@ -702,6 +712,28 @@ async function executeRequest(
return matchTitles(db, payload.titles);
}
case 'DB_FIND_TITLE_SOURCES': {
const payload = message.payload as {
request: FindTitleSourcesRequest;
};
return findTitleSources(db, payload.request);
}
case 'DB_GET_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return getVodSourcePin(db, payload.matchKeys);
}
case 'DB_SET_VOD_SOURCE_PIN': {
const payload = message.payload as { pin: VodSourcePin };
return setVodSourcePin(db, payload.pin);
}
case 'DB_CLEAR_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return clearVodSourcePin(db, payload.matchKeys);
}
case 'DB_DELETE_ALL_PLAYLISTS': {
const payload = message.payload as { operationId?: string };
+2
View File
@@ -29,6 +29,7 @@ import { registerStaticHeaderShims } from './app/services/request-header-overrid
import { AppUpdateService } from './app/services/app-update.service';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { registerStreamProbeHandlers } from './app/events/stream-probe';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
@@ -144,6 +145,7 @@ export default class Main {
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
@@ -34,6 +34,7 @@ export function createSettingsForm(
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
@@ -121,6 +122,7 @@ export function createSettingsFromFormValue(
webPlayerSharedControls: value.webPlayerSharedControls ?? false,
playerAmbientMode: value.playerAmbientMode ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
vodAutoFailover: value.vodAutoFailover ?? false,
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
@@ -133,6 +133,26 @@
</div>
}
@if (supportsVodMultiSource()) {
<div class="setting-item" data-test-id="vod-auto-failover-setting">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }}
</h4>
<p>
{{ 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="'SETTINGS.VOD_AUTO_FAILOVER' | translate"
formControlName="vodAutoFailover"
data-test-id="vod-auto-failover-toggle"
></mat-checkbox>
</div>
</div>
}
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
<div
class="setting-item"
@@ -51,6 +51,11 @@ export class SettingsPlaybackSectionComponent {
readonly frameCopyActive = input(false);
readonly supportsManagedExternalPlayers = input(false);
readonly supportsExternalPlayerPathSettings = input(false);
/**
* Cross-playlist movie matching is Electron-only, so the auto-failover
* toggle would control nothing in the PWA.
*/
readonly supportsVodMultiSource = input(false);
readonly selectRecordingFolder = output<void>();
isWebPlayerSelected(): boolean {
@@ -50,6 +50,7 @@
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
@@ -20,6 +20,7 @@ import { Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
@@ -104,6 +105,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
private readonly settingsCtx = inject(SettingsContextService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly matDialog = inject(MatDialog);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
@@ -129,6 +131,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.runtime.supportsManagedExternalPlayers;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly activeSection = this.settingsCtx.activeSection;
@@ -80,6 +80,7 @@ export const DEFAULT_SETTINGS = {
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "يُرسم الفيديو على لوحة التطبيق بحيث تظهر القوائم ومربعات الحوار فوقه.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "يسري المفعول بعد إعادة تشغيل التطبيق.",
"VOD_AUTO_FAILOVER": "التبديل التلقائي للمصدر عند فشل التشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "عند فشل تشغيل فيلم وتوفّر الفيلم نفسه في قائمة تشغيل أخرى، يتم التبديل إليها تلقائيًا مع الاحتفاظ بموضع التشغيل. الخيار معطّل افتراضيًا، ويُعلَن عن التبديل دائمًا لأن المصدر الآخر قد يحمل دبلجة مختلفة."
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -998,7 +1000,44 @@
"ITEM": "عنصر",
"ITEMS": "عناصر",
"NO_EPISODES": "لا توجد حلقات متاحة",
"ALL_RADIO": "كل الراديو"
"ALL_RADIO": "كل الراديو",
"MULTI_SOURCE": {
"SOURCES": "المصادر",
"SOURCE": "المصدر",
"SOURCES_FOR": "مصادر “{{title}}”",
"OPEN_SOURCES": "عرض المصادر البديلة",
"MATCHED_BY_TMDB": "تمت المطابقة عبر TMDB",
"MATCHED_BY_TITLE": "تمت المطابقة حسب العنوان",
"MATCH_BY_TITLE": "حسب العنوان",
"PLAYING": "قيد التشغيل",
"PLAY_FROM_SOURCE": "التشغيل من هذا المصدر",
"PIN_SOURCE": "اجعله المصدر الرئيسي",
"UNPIN_SOURCE": "إلغاء تثبيت هذا المصدر",
"CHECK": "فحص",
"CHECK_TOOLTIP": "التحقق من التوفر",
"PROBE_OK": "متاح",
"PROBE_FAIL": "غير متاح",
"PROBE_CHECKING": "جارٍ التحقق",
"AUTO_FAILOVER": "التبديل التلقائي عند الخطأ",
"PLAYING_FROM": "التشغيل من:",
"SWITCH_TITLE": "المصدر غير متاح — جارٍ التبديل إلى {{playlist}}",
"SWITCH_DETAIL": "الاستئناف عند {{timecode}}",
"SWITCH_AUDIO_WARNING": "قد تختلف الدبلجة",
"SWITCHED_TO": "تم التبديل إلى {{playlist}}",
"UNDO": "تراجع",
"CHOOSE_OTHER": "اختر مصدرًا آخر",
"TRY_ANOTHER_SOURCE": "جرّب مصدرًا آخر",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} لا توفّر هذا البث. هذا الفيلم موجود في قوائم التشغيل الأخرى لديك:",
"RESUME_KEPT": "سيتم الاحتفاظ بموضع التشغيل · {{timecode}}",
"ALL_SOURCES_TRIED": "تمت تجربة جميع المصادر.",
"ALSO_FOUND_IN_ONE": "— موجود أيضًا في قائمة تشغيل أخرى",
"ALSO_FOUND_IN_OTHER": "— موجود أيضًا في {{count}} من قوائم التشغيل الأخرى",
"CHECK_ALL": "فحص الكل",
"FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل",
"NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر",
"COPIES": "{{count}} نسخ في قائمة التشغيل هذه",
"MORE_SOURCES": "{{count}} أخرى في قائمة المصادر"
}
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "MPV المدمج: محرك نسخ الإطارات (تجريبي)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "الفيديو كيترسم على لوحة التطبيق باش القوائم والحوارات يبانو فوقو.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "غادي يخدم من بعد ما تعاود تشغل التطبيق.",
"VOD_AUTO_FAILOVER": "بدل المصدر أوتوماتيكياً عند خطأ فالتشغيل",
"VOD_AUTO_FAILOVER_DESCRIPTION": "ملي شي فيلم ما يتشغلش وكان نفس الفيلم كاين ف قائمة تشغيل أخرى، التطبيق كيبدل ليها أوتوماتيكياً وكيحافظ على التوقيت. معطل افتراضياً؛ التبديل ديما كيتعلن حيت مصدر آخر يمكن يكون فيه دوبلاج مختلف."
},
"THEMES": {
"DARK_THEME": "داكن",
@@ -998,7 +1000,44 @@
"ITEM": "عنصر",
"ITEMS": "عناصر",
"NO_EPISODES": "ما كاينش حلقات متوفرة",
"ALL_RADIO": "All radio"
"ALL_RADIO": "All radio",
"MULTI_SOURCE": {
"SOURCES": "مصادر",
"SOURCE": "مصدر",
"SOURCES_FOR": "مصادر ديال “{{title}}”",
"OPEN_SOURCES": "وري المصادر البديلة",
"MATCHED_BY_TMDB": "تطابق عن طريق TMDB",
"MATCHED_BY_TITLE": "تطابق عن طريق العنوان",
"MATCH_BY_TITLE": "بالعنوان",
"PLAYING": "كيتشغل",
"PLAY_FROM_SOURCE": "شغل من هاد المصدر",
"PIN_SOURCE": "دير هاد المصدر هو الرئيسي",
"UNPIN_SOURCE": "حيد هاد المصدر من الرئيسي",
"CHECK": "تحقق",
"CHECK_TOOLTIP": "تحقق من التوفر",
"PROBE_OK": "متوفر",
"PROBE_FAIL": "ما متوفرش",
"PROBE_CHECKING": "جاري التحقق",
"AUTO_FAILOVER": "بدل أوتوماتيكياً عند الخطأ",
"PLAYING_FROM": "كيتشغل من:",
"SWITCH_TITLE": "المصدر ما متوفرش — كنبدلو ل {{playlist}}",
"SWITCH_DETAIL": "غادي نكملو من {{timecode}}",
"SWITCH_AUDIO_WARNING": "يمكن الدوبلاج يكون مختلف",
"SWITCHED_TO": "تبدل ل {{playlist}}",
"UNDO": "تراجع",
"CHOOSE_OTHER": "اختار واحد آخر",
"TRY_ANOTHER_SOURCE": "جرب مصدر آخر",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} ما كيوفرش هاد البث. هاد الفيلم كاين ف قوائم التشغيل الأخرى ديالك:",
"RESUME_KEPT": "التوقيت غادي يتحفظ · {{timecode}}",
"ALL_SOURCES_TRIED": "جميع المصادر تجربو.",
"ALSO_FOUND_IN_ONE": "— كاين حتى ف قائمة تشغيل أخرى",
"ALSO_FOUND_IN_OTHER": "— كاين حتى ف {{count}} ديال قوائم التشغيل الأخرى",
"CHECK_ALL": "تحقق من الكل",
"FILTER_PLACEHOLDER": "فلتر بقائمة التشغيل",
"NO_MATCHES": "ما كاينش قوائم تشغيل مطابقة لهاد الفلتر",
"COPIES": "{{count}} ديال النسخ ف هاد قائمة التشغيل",
"MORE_SOURCES": "+{{count}} أخرى ف لائحة المصادر"
}
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: рухавік frame-copy (эксперыментальна)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Відэа малюецца на канвасе праграмы, таму меню і дыялогі паказваюцца па-над ім.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Пачне дзейнічаць пасля перазапуску праграмы.",
"VOD_AUTO_FAILOVER": "Аўтапераключэнне крыніцы пры памылцы прайгравання",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Калі фільм не прайграецца, а той жа фільм ёсць у іншым плэйлісце, аўтаматычна пераключацца на яго, захоўваючы пазіцыю прайгравання. Па змаўчанні выключана; пра пераключэнне заўсёды паведамляецца, бо ў іншай крыніцы можа быць іншая агучка."
},
"THEMES": {
"DARK_THEME": "Цёмная",
@@ -998,7 +1000,44 @@
"ITEM": "элемент",
"ITEMS": "элементаў",
"NO_EPISODES": "Няма даступных эпізодаў",
"ALL_RADIO": "Усе радыё"
"ALL_RADIO": "Усе радыё",
"MULTI_SOURCE": {
"SOURCES": "Крыніцы",
"SOURCE": "Крыніца",
"SOURCES_FOR": "Крыніцы для «{{title}}»",
"OPEN_SOURCES": "Паказаць альтэрнатыўныя крыніцы",
"MATCHED_BY_TMDB": "супастаўлена па TMDB",
"MATCHED_BY_TITLE": "супастаўлена па назве",
"MATCH_BY_TITLE": "па назве",
"PLAYING": "Прайграецца",
"PLAY_FROM_SOURCE": "Прайграць з гэтай крыніцы",
"PIN_SOURCE": "Зрабіць асноўнай крыніцай",
"UNPIN_SOURCE": "Адмацаваць гэтую крыніцу",
"CHECK": "праверыць",
"CHECK_TOOLTIP": "Праверыць даступнасць",
"PROBE_OK": "даступна",
"PROBE_FAIL": "недаступна",
"PROBE_CHECKING": "праверка",
"AUTO_FAILOVER": "Аўтапераключэнне пры памылцы",
"PLAYING_FROM": "Прайграецца з:",
"SWITCH_TITLE": "Крыніца недаступная — пераключаемся на {{playlist}}",
"SWITCH_DETAIL": "працягваем з {{timecode}}",
"SWITCH_AUDIO_WARNING": "агучка можа адрознівацца",
"SWITCHED_TO": "Пераключана на {{playlist}}",
"UNDO": "Вярнуць",
"CHOOSE_OTHER": "Выбраць іншую",
"TRY_ANOTHER_SOURCE": "Паспрабаваць іншую крыніцу",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} не аддае гэты паток. Гэты фільм ёсць у іншых вашых плэйлістах:",
"RESUME_KEPT": "пазіцыя захаваецца · {{timecode}}",
"ALL_SOURCES_TRIED": "Усе крыніцы ўжо выпрабаваны.",
"ALSO_FOUND_IN_ONE": "— таксама ёсць яшчэ ў адным плэйлісце",
"ALSO_FOUND_IN_OTHER": "— таксама ёсць яшчэ ў {{count}} плэйлістах",
"CHECK_ALL": "праверыць усе",
"FILTER_PLACEHOLDER": "Фільтраваць па плэйлісце",
"NO_MATCHES": "Ніводны плэйліст не адпавядае гэтаму фільтру",
"COPIES": "{{count}} копій у гэтым плэйлісце",
"MORE_SOURCES": "+{{count}} у спісе крыніц"
}
},
"INFORMATION": "Інфармацыя",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: Frame-Copy-Engine (experimentell)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Das Video wird auf die App-Leinwand gerendert, sodass Menüs und Dialoge darüber liegen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wird nach einem Neustart der App wirksam.",
"VOD_AUTO_FAILOVER": "Quelle bei Wiedergabefehler automatisch wechseln",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Wenn sich ein Film nicht abspielen lässt und derselbe Film in einer anderen Playlist vorhanden ist, wird automatisch dorthin gewechselt und die Wiedergabeposition beibehalten. Standardmäßig deaktiviert; der Wechsel wird immer angekündigt, da eine andere Quelle eine andere Synchronfassung enthalten kann."
},
"THEMES": {
"DARK_THEME": "Dunkel",
@@ -998,7 +1000,44 @@
"ITEM": "Element",
"ITEMS": "Elemente",
"NO_EPISODES": "Keine Episoden verfügbar",
"ALL_RADIO": "Alle Radiosender"
"ALL_RADIO": "Alle Radiosender",
"MULTI_SOURCE": {
"SOURCES": "Quellen",
"SOURCE": "Quelle",
"SOURCES_FOR": "Quellen für „{{title}}“",
"OPEN_SOURCES": "Alternative Quellen anzeigen",
"MATCHED_BY_TMDB": "über TMDB zugeordnet",
"MATCHED_BY_TITLE": "über den Titel zugeordnet",
"MATCH_BY_TITLE": "per Titel",
"PLAYING": "Läuft",
"PLAY_FROM_SOURCE": "Von dieser Quelle abspielen",
"PIN_SOURCE": "Als Hauptquelle festlegen",
"UNPIN_SOURCE": "Hauptquelle aufheben",
"CHECK": "prüfen",
"CHECK_TOOLTIP": "Verfügbarkeit prüfen",
"PROBE_OK": "verfügbar",
"PROBE_FAIL": "nicht verfügbar",
"PROBE_CHECKING": "wird geprüft",
"AUTO_FAILOVER": "Bei Fehler automatisch wechseln",
"PLAYING_FROM": "Wiedergabe von:",
"SWITCH_TITLE": "Quelle nicht verfügbar — Wechsel zu {{playlist}}",
"SWITCH_DETAIL": "wird bei {{timecode}} fortgesetzt",
"SWITCH_AUDIO_WARNING": "die Synchronfassung kann abweichen",
"SWITCHED_TO": "Zu {{playlist}} gewechselt",
"UNDO": "Rückgängig",
"CHOOSE_OTHER": "Andere wählen",
"TRY_ANOTHER_SOURCE": "Andere Quelle versuchen",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} liefert diesen Stream nicht. Dieser Film ist in Ihren anderen Playlists vorhanden:",
"RESUME_KEPT": "Wiedergabeposition wird beibehalten · {{timecode}}",
"ALL_SOURCES_TRIED": "Alle Quellen wurden bereits versucht.",
"ALSO_FOUND_IN_ONE": "— auch in einer weiteren Playlist gefunden",
"ALSO_FOUND_IN_OTHER": "— auch in {{count}} weiteren Playlists gefunden",
"CHECK_ALL": "alle prüfen",
"FILTER_PLACEHOLDER": "Nach Playlist filtern",
"NO_MATCHES": "Keine Playlists entsprechen dem Filter",
"COPIES": "{{count}} Kopien in dieser Playlist",
"MORE_SOURCES": "+{{count}} weitere unter „Quellen“"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: μηχανή frame-copy (πειραματικό)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Το βίντεο σχεδιάζεται στον καμβά της εφαρμογής, ώστε τα μενού και τα παράθυρα διαλόγου να εμφανίζονται από πάνω του.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ισχύει μετά την επανεκκίνηση της εφαρμογής.",
"VOD_AUTO_FAILOVER": "Αυτόματη αλλαγή πηγής σε σφάλμα αναπαραγωγής",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Όταν μια ταινία δεν μπορεί να αναπαραχθεί και η ίδια ταινία υπάρχει σε άλλη λίστα αναπαραγωγής, γίνεται αυτόματη μετάβαση σε αυτήν με διατήρηση της θέσης αναπαραγωγής. Απενεργοποιημένο από προεπιλογή· η αλλαγή ανακοινώνεται πάντα, επειδή μια άλλη πηγή μπορεί να έχει διαφορετική μεταγλώττιση."
},
"THEMES": {
"DARK_THEME": "Σκούρο",
@@ -998,7 +1000,44 @@
"ITEM": "στοιχείο",
"ITEMS": "στοιχεία",
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια",
"ALL_RADIO": "Όλα τα ραδιόφωνα"
"ALL_RADIO": "Όλα τα ραδιόφωνα",
"MULTI_SOURCE": {
"SOURCES": "Πηγές",
"SOURCE": "Πηγή",
"SOURCES_FOR": "Πηγές για «{{title}}»",
"OPEN_SOURCES": "Εμφάνιση εναλλακτικών πηγών",
"MATCHED_BY_TMDB": "αντιστοιχίστηκε μέσω TMDB",
"MATCHED_BY_TITLE": "αντιστοιχίστηκε βάσει τίτλου",
"MATCH_BY_TITLE": "βάσει τίτλου",
"PLAYING": "Παίζει",
"PLAY_FROM_SOURCE": "Αναπαραγωγή από αυτήν την πηγή",
"PIN_SOURCE": "Ορισμός ως κύρια πηγή",
"UNPIN_SOURCE": "Κατάργηση κύριας πηγής",
"CHECK": "έλεγχος",
"CHECK_TOOLTIP": "Έλεγχος διαθεσιμότητας",
"PROBE_OK": "διαθέσιμη",
"PROBE_FAIL": "μη διαθέσιμη",
"PROBE_CHECKING": "ελέγχεται",
"AUTO_FAILOVER": "Αυτόματη αλλαγή σε σφάλμα",
"PLAYING_FROM": "Αναπαραγωγή από:",
"SWITCH_TITLE": "Η πηγή δεν είναι διαθέσιμη — μετάβαση σε {{playlist}}",
"SWITCH_DETAIL": "συνέχεια από {{timecode}}",
"SWITCH_AUDIO_WARNING": "η μεταγλώττιση μπορεί να διαφέρει",
"SWITCHED_TO": "Έγινε μετάβαση σε {{playlist}}",
"UNDO": "Αναίρεση",
"CHOOSE_OTHER": "Άλλη πηγή",
"TRY_ANOTHER_SOURCE": "Δοκιμή άλλης πηγής",
"ERROR_HAS_ALTERNATIVES": "Η λίστα {{playlist}} δεν παρέχει αυτήν τη ροή. Αυτή η ταινία υπάρχει στις άλλες λίστες αναπαραγωγής σας:",
"RESUME_KEPT": "η θέση αναπαραγωγής θα διατηρηθεί · {{timecode}}",
"ALL_SOURCES_TRIED": "Δοκιμάστηκαν όλες οι πηγές.",
"ALSO_FOUND_IN_ONE": "— βρέθηκε επίσης σε μία άλλη λίστα αναπαραγωγής",
"ALSO_FOUND_IN_OTHER": "— βρέθηκε επίσης σε {{count}} άλλες λίστες αναπαραγωγής",
"CHECK_ALL": "έλεγχος όλων",
"FILTER_PLACEHOLDER": "Φιλτράρισμα κατά λίστα αναπαραγωγής",
"NO_MATCHES": "Δεν βρέθηκαν αντίστοιχες λίστες αναπαραγωγής",
"COPIES": "{{count}} αντίγραφα σε αυτήν τη λίστα",
"MORE_SOURCES": "+{{count}} ακόμη στη λίστα πηγών"
}
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy engine (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Render video onto the app canvas so menus and dialogs stack above it.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Takes effect after restarting the app.",
"VOD_AUTO_FAILOVER": "Auto-switch source on playback error",
"VOD_AUTO_FAILOVER_DESCRIPTION": "When a movie fails to play and the same film exists in another playlist, switch to it automatically and keep the timecode. Off by default; the switch is always announced because another source can carry a different dub."
},
"THEMES": {
"DARK_THEME": "Dark",
@@ -998,7 +1000,44 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "No episodes available",
"ALL_RADIO": "All radio"
"ALL_RADIO": "All radio",
"MULTI_SOURCE": {
"SOURCES": "Sources",
"SOURCE": "Source",
"SOURCES_FOR": "Sources for “{{title}}”",
"OPEN_SOURCES": "Show alternative sources",
"MATCHED_BY_TMDB": "matched by TMDB",
"MATCHED_BY_TITLE": "matched by title",
"MATCH_BY_TITLE": "by title",
"PLAYING": "Playing",
"PLAY_FROM_SOURCE": "Play from this source",
"PIN_SOURCE": "Make this the main source",
"UNPIN_SOURCE": "Unpin this source",
"CHECK": "check",
"CHECK_TOOLTIP": "Check availability",
"PROBE_OK": "available",
"PROBE_FAIL": "unavailable",
"PROBE_CHECKING": "checking",
"AUTO_FAILOVER": "Auto-switch on error",
"PLAYING_FROM": "Playing from:",
"SWITCH_TITLE": "Source unavailable — switching to {{playlist}}",
"SWITCH_DETAIL": "resuming at {{timecode}}",
"SWITCH_AUDIO_WARNING": "the dub may differ",
"SWITCHED_TO": "Switched to {{playlist}}",
"UNDO": "Undo",
"CHOOSE_OTHER": "Choose another",
"TRY_ANOTHER_SOURCE": "Try another source",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} is not serving this stream. This movie is in your other playlists:",
"RESUME_KEPT": "timecode will be kept · {{timecode}}",
"ALL_SOURCES_TRIED": "Every source has been tried.",
"ALSO_FOUND_IN_ONE": "— also found in 1 other playlist",
"ALSO_FOUND_IN_OTHER": "— also found in {{count}} other playlists",
"CHECK_ALL": "check all",
"FILTER_PLACEHOLDER": "Filter by playlist",
"NO_MATCHES": "No playlists match that filter",
"COPIES": "{{count}} copies in this playlist",
"MORE_SOURCES": "+{{count}} more in the sources list"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "El vídeo se dibuja en el lienzo de la aplicación, de modo que los menús y diálogos quedan encima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Se aplica tras reiniciar la aplicación.",
"VOD_AUTO_FAILOVER": "Cambiar de fuente automáticamente al fallar la reproducción",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Cuando una película no se puede reproducir y la misma película está en otra lista, cambia a ella automáticamente y conserva la posición de reproducción. Desactivado de forma predeterminada; el cambio siempre se avisa, porque otra fuente puede tener un doblaje distinto."
},
"THEMES": {
"DARK_THEME": "Oscuro",
@@ -998,7 +1000,44 @@
"ITEM": "elemento",
"ITEMS": "elementos",
"NO_EPISODES": "No hay episodios disponibles",
"ALL_RADIO": "Todas las radios"
"ALL_RADIO": "Todas las radios",
"MULTI_SOURCE": {
"SOURCES": "Fuentes",
"SOURCE": "Fuente",
"SOURCES_FOR": "Fuentes para “{{title}}”",
"OPEN_SOURCES": "Mostrar fuentes alternativas",
"MATCHED_BY_TMDB": "coincidencia por TMDB",
"MATCHED_BY_TITLE": "coincidencia por título",
"MATCH_BY_TITLE": "por título",
"PLAYING": "Reproduciendo",
"PLAY_FROM_SOURCE": "Reproducir desde esta fuente",
"PIN_SOURCE": "Fijar como fuente principal",
"UNPIN_SOURCE": "Dejar de fijar esta fuente",
"CHECK": "comprobar",
"CHECK_TOOLTIP": "Comprobar disponibilidad",
"PROBE_OK": "disponible",
"PROBE_FAIL": "no disponible",
"PROBE_CHECKING": "comprobando",
"AUTO_FAILOVER": "Cambio automático al fallar",
"PLAYING_FROM": "Reproduciendo desde:",
"SWITCH_TITLE": "Fuente no disponible — cambiando a {{playlist}}",
"SWITCH_DETAIL": "se reanuda en {{timecode}}",
"SWITCH_AUDIO_WARNING": "el doblaje puede ser distinto",
"SWITCHED_TO": "Se cambió a {{playlist}}",
"UNDO": "Deshacer",
"CHOOSE_OTHER": "Elegir otra",
"TRY_ANOTHER_SOURCE": "Probar otra fuente",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} no está ofreciendo esta transmisión. Esta película está en tus otras listas:",
"RESUME_KEPT": "se conservará la posición · {{timecode}}",
"ALL_SOURCES_TRIED": "Se han probado todas las fuentes.",
"ALSO_FOUND_IN_ONE": "— también está en otra lista",
"ALSO_FOUND_IN_OTHER": "— también está en otras {{count}} listas",
"CHECK_ALL": "comprobar todas",
"FILTER_PLACEHOLDER": "Filtrar por lista",
"NO_MATCHES": "Ninguna lista coincide con ese filtro",
"COPIES": "{{count}} copias en esta lista",
"MORE_SOURCES": "+{{count}} más en la lista de fuentes"
}
},
"INFORMATION": "Información",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV : moteur frame-copy (expérimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "La vidéo est rendue sur le canevas de l'application, les menus et boîtes de dialogue s'affichent donc au-dessus.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Prend effet après le redémarrage de l'application.",
"VOD_AUTO_FAILOVER": "Changer de source automatiquement en cas d'erreur de lecture",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Lorsqu'un film ne parvient pas à démarrer et que le même film existe dans une autre liste de lecture, bascule automatiquement vers celle-ci en conservant la position de lecture. Désactivé par défaut ; le changement est toujours annoncé, car une autre source peut proposer un doublage différent."
},
"THEMES": {
"DARK_THEME": "Sombre",
@@ -998,7 +1000,44 @@
"ITEM": "élément",
"ITEMS": "éléments",
"NO_EPISODES": "Aucun épisode disponible",
"ALL_RADIO": "Toutes les radios"
"ALL_RADIO": "Toutes les radios",
"MULTI_SOURCE": {
"SOURCES": "Sources",
"SOURCE": "Source",
"SOURCES_FOR": "Sources pour « {{title}} »",
"OPEN_SOURCES": "Afficher les autres sources",
"MATCHED_BY_TMDB": "correspondance trouvée via TMDB",
"MATCHED_BY_TITLE": "correspondance trouvée d'après le titre",
"MATCH_BY_TITLE": "par titre",
"PLAYING": "En lecture",
"PLAY_FROM_SOURCE": "Lire depuis cette source",
"PIN_SOURCE": "Définir comme source principale",
"UNPIN_SOURCE": "Ne plus utiliser comme source principale",
"CHECK": "vérifier",
"CHECK_TOOLTIP": "Vérifier la disponibilité",
"PROBE_OK": "disponible",
"PROBE_FAIL": "indisponible",
"PROBE_CHECKING": "vérification",
"AUTO_FAILOVER": "Bascule automatique en cas d'erreur",
"PLAYING_FROM": "Lecture depuis :",
"SWITCH_TITLE": "Source indisponible — bascule vers {{playlist}}",
"SWITCH_DETAIL": "reprise à {{timecode}}",
"SWITCH_AUDIO_WARNING": "le doublage peut être différent",
"SWITCHED_TO": "Basculé vers {{playlist}}",
"UNDO": "Annuler",
"CHOOSE_OTHER": "Choisir une autre source",
"TRY_ANOTHER_SOURCE": "Essayer une autre source",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} ne diffuse pas ce flux. Ce film est présent dans vos autres listes de lecture :",
"RESUME_KEPT": "la position de lecture sera conservée · {{timecode}}",
"ALL_SOURCES_TRIED": "Toutes les sources ont été essayées.",
"ALSO_FOUND_IN_ONE": "— également disponible dans une autre liste de lecture",
"ALSO_FOUND_IN_OTHER": "— également disponible dans {{count}} autres listes de lecture",
"CHECK_ALL": "tout vérifier",
"FILTER_PLACEHOLDER": "Filtrer par liste de lecture",
"NO_MATCHES": "Aucune liste de lecture ne correspond à ce filtre",
"COPIES": "{{count}} copies dans cette liste de lecture",
"MORE_SOURCES": "+{{count}} dans la liste des sources"
}
},
"INFORMATION": "Information",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "Újdonságok",
"EMBEDDED_MPV_FRAME_COPY": "Beágyazott MPV: képkocka-másolásos megjelenítés (kísérleti)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "A videó megjelenítése az alkalmazás rajzfelületén, hogy a menük és párbeszédpanelek fölötte jelenhessenek meg.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Az alkalmazás újraindítása után lép érvénybe.",
"VOD_AUTO_FAILOVER": "Automatikus forrásváltás lejátszási hiba esetén",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Ha egy film lejátszása meghiúsul, és ugyanaz a film egy másik lejátszási listában is megtalálható, az alkalmazás automatikusan átvált rá, és megőrzi a lejátszási pozíciót. Alapértelmezés szerint kikapcsolva; a váltásról mindig értesítés jelenik meg, mert egy másik forráson eltérő szinkron lehet."
},
"THEMES": {
"DARK_THEME": "Sötét",
@@ -998,7 +1000,44 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "Nincs elérhető epizód",
"ALL_RADIO": "Összes rádió"
"ALL_RADIO": "Összes rádió",
"MULTI_SOURCE": {
"SOURCES": "Források",
"SOURCE": "Forrás",
"SOURCES_FOR": "„{{title}}” forrásai",
"OPEN_SOURCES": "Alternatív források megjelenítése",
"MATCHED_BY_TMDB": "TMDB alapján párosítva",
"MATCHED_BY_TITLE": "Cím alapján párosítva",
"MATCH_BY_TITLE": "cím alapján",
"PLAYING": "Lejátszás alatt",
"PLAY_FROM_SOURCE": "Lejátszás erről a forrásról",
"PIN_SOURCE": "Beállítás fő forrásként",
"UNPIN_SOURCE": "Fő forrás beállításának visszavonása",
"CHECK": "ellenőrzés",
"CHECK_TOOLTIP": "Elérhetőség ellenőrzése",
"PROBE_OK": "elérhető",
"PROBE_FAIL": "nem elérhető",
"PROBE_CHECKING": "ellenőrzés…",
"AUTO_FAILOVER": "Automatikus váltás hiba esetén",
"PLAYING_FROM": "Lejátszás innen:",
"SWITCH_TITLE": "A forrás nem érhető el — váltás erre: {{playlist}}",
"SWITCH_DETAIL": "folytatás innen: {{timecode}}",
"SWITCH_AUDIO_WARNING": "a szinkron eltérhet",
"SWITCHED_TO": "Átváltva erre: {{playlist}}",
"UNDO": "Visszavonás",
"CHOOSE_OTHER": "Másik választása",
"TRY_ANOTHER_SOURCE": "Másik forrás kipróbálása",
"ERROR_HAS_ALTERNATIVES": "Ez az adatfolyam nem érhető el innen: {{playlist}}. A film a többi lejátszási listájában is megtalálható:",
"RESUME_KEPT": "a lejátszási pozíció megmarad · {{timecode}}",
"ALL_SOURCES_TRIED": "Az összes forrás kipróbálva.",
"ALSO_FOUND_IN_ONE": "— megtalálható még egy másik lejátszási listában",
"ALSO_FOUND_IN_OTHER": "— megtalálható még {{count}} másik lejátszási listában",
"CHECK_ALL": "összes ellenőrzése",
"FILTER_PLACEHOLDER": "Szűrés lejátszási lista alapján",
"NO_MATCHES": "Nincs a szűrőnek megfelelő lejátszási lista",
"COPIES": "{{count}} másolat ebben a listában",
"MORE_SOURCES": "+{{count}} további a forráslistában"
}
},
"INFORMATION": "Információk",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motore frame-copy (sperimentale)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Il video viene disegnato sul canvas dell'app, così menu e finestre di dialogo appaiono sopra di esso.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Ha effetto dopo il riavvio dell'app.",
"VOD_AUTO_FAILOVER": "Cambia sorgente automaticamente in caso di errore di riproduzione",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando la riproduzione di un film non riesce e lo stesso film è presente in un'altra playlist, l'app passa automaticamente a quella sorgente mantenendo la posizione di riproduzione. Disattivo per impostazione predefinita; il cambio viene sempre segnalato perché un'altra sorgente può avere un doppiaggio diverso."
},
"THEMES": {
"DARK_THEME": "Scuro",
@@ -998,7 +1000,44 @@
"ITEM": "elemento",
"ITEMS": "elementi",
"NO_EPISODES": "Nessun episodio disponibile",
"ALL_RADIO": "Tutte le radio"
"ALL_RADIO": "Tutte le radio",
"MULTI_SOURCE": {
"SOURCES": "Sorgenti",
"SOURCE": "Sorgente",
"SOURCES_FOR": "Sorgenti per “{{title}}”",
"OPEN_SOURCES": "Mostra sorgenti alternative",
"MATCHED_BY_TMDB": "abbinato tramite TMDB",
"MATCHED_BY_TITLE": "abbinato per titolo",
"MATCH_BY_TITLE": "per titolo",
"PLAYING": "In riproduzione",
"PLAY_FROM_SOURCE": "Riproduci da questa sorgente",
"PIN_SOURCE": "Imposta come sorgente principale",
"UNPIN_SOURCE": "Rimuovi come sorgente principale",
"CHECK": "verifica",
"CHECK_TOOLTIP": "Verifica disponibilità",
"PROBE_OK": "disponibile",
"PROBE_FAIL": "non disponibile",
"PROBE_CHECKING": "verifica in corso",
"AUTO_FAILOVER": "Cambio automatico in caso di errore",
"PLAYING_FROM": "In riproduzione da:",
"SWITCH_TITLE": "Sorgente non disponibile — passaggio a {{playlist}}",
"SWITCH_DETAIL": "ripresa da {{timecode}}",
"SWITCH_AUDIO_WARNING": "il doppiaggio potrebbe essere diverso",
"SWITCHED_TO": "Passato a {{playlist}}",
"UNDO": "Annulla",
"CHOOSE_OTHER": "Scegli un'altra sorgente",
"TRY_ANOTHER_SOURCE": "Prova un'altra sorgente",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} non fornisce questo stream. Questo film è presente nelle tue altre playlist:",
"RESUME_KEPT": "la posizione verrà mantenuta · {{timecode}}",
"ALL_SOURCES_TRIED": "Sono state provate tutte le sorgenti.",
"ALSO_FOUND_IN_ONE": "— presente anche in un'altra playlist",
"ALSO_FOUND_IN_OTHER": "— presente anche in altre {{count}} playlist",
"CHECK_ALL": "verifica tutte",
"FILTER_PLACEHOLDER": "Filtra per playlist",
"NO_MATCHES": "Nessuna playlist corrisponde al filtro",
"COPIES": "{{count}} copie in questa playlist",
"MORE_SOURCES": "+{{count}} nell'elenco sorgenti"
}
},
"INFORMATION": "Informazioni",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "埋め込みMPV:フレームコピーエンジン(実験的)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "映像をアプリのキャンバスに描画するため、メニューやダイアログをその上に表示できます。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "アプリの再起動後に有効になります。",
"VOD_AUTO_FAILOVER": "再生エラー時にソースを自動切り替え",
"VOD_AUTO_FAILOVER_DESCRIPTION": "映画の再生に失敗したとき、同じ作品が別のプレイリストにもある場合は自動的にそちらへ切り替え、再生位置を引き継ぎます。標準では無効です。別のソースでは吹き替えが異なる場合があるため、切り替え時は必ず通知されます。"
},
"THEMES": {
"DARK_THEME": "ダーク",
@@ -998,7 +1000,44 @@
"ITEM": "件",
"ITEMS": "件",
"NO_EPISODES": "利用可能なエピソードはありません",
"ALL_RADIO": "すべてのラジオ"
"ALL_RADIO": "すべてのラジオ",
"MULTI_SOURCE": {
"SOURCES": "ソース",
"SOURCE": "ソース",
"SOURCES_FOR": "「{{title}}」のソース",
"OPEN_SOURCES": "他のソースを表示",
"MATCHED_BY_TMDB": "TMDB で一致",
"MATCHED_BY_TITLE": "タイトルで一致",
"MATCH_BY_TITLE": "タイトル一致",
"PLAYING": "再生中",
"PLAY_FROM_SOURCE": "このソースから再生",
"PIN_SOURCE": "メインのソースに設定",
"UNPIN_SOURCE": "メインのソース設定を解除",
"CHECK": "確認",
"CHECK_TOOLTIP": "利用可能か確認",
"PROBE_OK": "利用可能",
"PROBE_FAIL": "利用不可",
"PROBE_CHECKING": "確認中",
"AUTO_FAILOVER": "エラー時に自動切り替え",
"PLAYING_FROM": "再生元:",
"SWITCH_TITLE": "ソースを利用できません — {{playlist}} に切り替えます",
"SWITCH_DETAIL": "{{timecode}} から再開します",
"SWITCH_AUDIO_WARNING": "吹き替えが異なる場合があります",
"SWITCHED_TO": "{{playlist}} に切り替えました",
"UNDO": "元に戻す",
"CHOOSE_OTHER": "別のソースを選択",
"TRY_ANOTHER_SOURCE": "別のソースを試す",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} はこのストリームを配信していません。この映画は他のプレイリストにもあります:",
"RESUME_KEPT": "再生位置は引き継がれます · {{timecode}}",
"ALL_SOURCES_TRIED": "すべてのソースを試しました。",
"ALSO_FOUND_IN_ONE": "— 他のプレイリストにもあります",
"ALSO_FOUND_IN_OTHER": "— 他の {{count}} 件のプレイリストにもあります",
"CHECK_ALL": "すべて確認",
"FILTER_PLACEHOLDER": "プレイリストでフィルター",
"NO_MATCHES": "このフィルターに一致するプレイリストはありません",
"COPIES": "このプレイリスト内に{{count}}件",
"MORE_SOURCES": "他 {{count}} 件はソース一覧から"
}
},
"INFORMATION": "情報",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "내장 MPV: 프레임 복사 엔진(실험적)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "비디오를 앱 캔버스에 렌더링하여 메뉴와 대화 상자가 그 위에 표시됩니다.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "앱을 다시 시작한 후 적용됩니다.",
"VOD_AUTO_FAILOVER": "재생 오류 시 소스 자동 전환",
"VOD_AUTO_FAILOVER_DESCRIPTION": "영화 재생에 실패했을 때 같은 작품이 다른 재생목록에 있으면 재생 위치를 유지한 채 자동으로 전환합니다. 기본값은 꺼짐이며, 다른 소스는 더빙이 다를 수 있으므로 전환 시 항상 알려 드립니다."
},
"THEMES": {
"DARK_THEME": "어둡게",
@@ -998,7 +1000,44 @@
"ITEM": "개",
"ITEMS": "개",
"NO_EPISODES": "사용 가능한 에피소드가 없습니다",
"ALL_RADIO": "모든 라디오"
"ALL_RADIO": "모든 라디오",
"MULTI_SOURCE": {
"SOURCES": "소스",
"SOURCE": "소스",
"SOURCES_FOR": "“{{title}}”의 소스",
"OPEN_SOURCES": "대체 소스 표시",
"MATCHED_BY_TMDB": "TMDB로 매칭됨",
"MATCHED_BY_TITLE": "제목으로 매칭됨",
"MATCH_BY_TITLE": "제목 기준",
"PLAYING": "재생 중",
"PLAY_FROM_SOURCE": "이 소스에서 재생",
"PIN_SOURCE": "기본 소스로 지정",
"UNPIN_SOURCE": "기본 소스 지정 해제",
"CHECK": "확인",
"CHECK_TOOLTIP": "사용 가능 여부 확인",
"PROBE_OK": "사용 가능",
"PROBE_FAIL": "사용 불가",
"PROBE_CHECKING": "확인 중",
"AUTO_FAILOVER": "오류 시 자동 전환",
"PLAYING_FROM": "재생 중인 소스:",
"SWITCH_TITLE": "소스를 사용할 수 없습니다 — {{playlist}}(으)로 전환합니다",
"SWITCH_DETAIL": "{{timecode}}부터 이어서 재생합니다",
"SWITCH_AUDIO_WARNING": "더빙이 다를 수 있습니다",
"SWITCHED_TO": "{{playlist}}(으)로 전환했습니다",
"UNDO": "실행 취소",
"CHOOSE_OTHER": "다른 소스 선택",
"TRY_ANOTHER_SOURCE": "다른 소스로 시도",
"ERROR_HAS_ALTERNATIVES": "{{playlist}}에서 이 스트림을 제공하지 않습니다. 이 영화는 다른 재생목록에도 있습니다:",
"RESUME_KEPT": "재생 위치가 유지됩니다 · {{timecode}}",
"ALL_SOURCES_TRIED": "모든 소스를 시도했습니다.",
"ALSO_FOUND_IN_ONE": "— 다른 재생목록 1개에도 있음",
"ALSO_FOUND_IN_OTHER": "— 다른 재생목록 {{count}}개에도 있음",
"CHECK_ALL": "모두 확인",
"FILTER_PLACEHOLDER": "재생목록으로 필터",
"NO_MATCHES": "이 필터에 해당하는 재생목록이 없습니다",
"COPIES": "이 재생목록에 소스 {{count}}개",
"MORE_SOURCES": "소스 목록에 {{count}}개 더 있음"
}
},
"INFORMATION": "정보",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy-engine (experimenteel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "De video wordt op het app-canvas getekend zodat menu's en dialoogvensters erboven verschijnen.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Wordt van kracht na het herstarten van de app.",
"VOD_AUTO_FAILOVER": "Automatisch van bron wisselen bij een afspeelfout",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Als een film niet kan worden afgespeeld en dezelfde film in een andere afspeellijst staat, wordt daar automatisch naar overgeschakeld met behoud van de afspeelpositie. Standaard uitgeschakeld; de wissel wordt altijd gemeld, omdat een andere bron een andere nasynchronisatie kan hebben."
},
"THEMES": {
"DARK_THEME": "Donker",
@@ -998,7 +1000,44 @@
"ITEM": "item",
"ITEMS": "items",
"NO_EPISODES": "Geen afleveringen beschikbaar",
"ALL_RADIO": "Alle radio"
"ALL_RADIO": "Alle radio",
"MULTI_SOURCE": {
"SOURCES": "Bronnen",
"SOURCE": "Bron",
"SOURCES_FOR": "Bronnen voor “{{title}}”",
"OPEN_SOURCES": "Alternatieve bronnen tonen",
"MATCHED_BY_TMDB": "gekoppeld via TMDB",
"MATCHED_BY_TITLE": "gekoppeld op titel",
"MATCH_BY_TITLE": "op titel",
"PLAYING": "Speelt nu",
"PLAY_FROM_SOURCE": "Afspelen vanaf deze bron",
"PIN_SOURCE": "Instellen als hoofdbron",
"UNPIN_SOURCE": "Niet meer als hoofdbron gebruiken",
"CHECK": "controleren",
"CHECK_TOOLTIP": "Beschikbaarheid controleren",
"PROBE_OK": "beschikbaar",
"PROBE_FAIL": "niet beschikbaar",
"PROBE_CHECKING": "wordt gecontroleerd",
"AUTO_FAILOVER": "Automatisch wisselen bij fout",
"PLAYING_FROM": "Speelt af vanaf:",
"SWITCH_TITLE": "Bron niet beschikbaar — overschakelen naar {{playlist}}",
"SWITCH_DETAIL": "wordt hervat op {{timecode}}",
"SWITCH_AUDIO_WARNING": "de nasynchronisatie kan afwijken",
"SWITCHED_TO": "Overgeschakeld naar {{playlist}}",
"UNDO": "Ongedaan maken",
"CHOOSE_OTHER": "Andere bron kiezen",
"TRY_ANOTHER_SOURCE": "Andere bron proberen",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} levert deze stream niet. Deze film staat wel in je andere afspeellijsten:",
"RESUME_KEPT": "afspeelpositie blijft behouden · {{timecode}}",
"ALL_SOURCES_TRIED": "Alle bronnen zijn geprobeerd.",
"ALSO_FOUND_IN_ONE": "— ook gevonden in één andere afspeellijst",
"ALSO_FOUND_IN_OTHER": "— ook gevonden in {{count}} andere afspeellijsten",
"CHECK_ALL": "alles controleren",
"FILTER_PLACEHOLDER": "Filter op afspeellijst",
"NO_MATCHES": "Geen afspeellijsten komen overeen met dit filter",
"COPIES": "{{count}} kopieën in deze afspeellijst",
"MORE_SOURCES": "+{{count}} meer in de bronnenlijst"
}
},
"INFORMATION": "Informatie",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: silnik frame-copy (eksperymentalny)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Wideo jest rysowane na kanwie aplikacji, dzięki czemu menu i okna dialogowe wyświetlają się nad nim.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Zacznie działać po ponownym uruchomieniu aplikacji.",
"VOD_AUTO_FAILOVER": "Automatyczna zmiana źródła przy błędzie odtwarzania",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Gdy filmu nie da się odtworzyć, a ten sam tytuł jest dostępny na innej liście odtwarzania, przełącz się na nią automatycznie i zachowaj pozycję odtwarzania. Domyślnie wyłączone; zmiana jest zawsze sygnalizowana, ponieważ inne źródło może mieć inną wersję językową."
},
"THEMES": {
"DARK_THEME": "Ciemny",
@@ -998,7 +1000,44 @@
"ITEM": "pozycja",
"ITEMS": "pozycji",
"NO_EPISODES": "Brak dostępnych odcinków",
"ALL_RADIO": "Wszystkie radia"
"ALL_RADIO": "Wszystkie radia",
"MULTI_SOURCE": {
"SOURCES": "Źródła",
"SOURCE": "Źródło",
"SOURCES_FOR": "Źródła dla „{{title}}”",
"OPEN_SOURCES": "Pokaż alternatywne źródła",
"MATCHED_BY_TMDB": "dopasowano na podstawie TMDB",
"MATCHED_BY_TITLE": "dopasowano na podstawie tytułu",
"MATCH_BY_TITLE": "wg tytułu",
"PLAYING": "Odtwarzane",
"PLAY_FROM_SOURCE": "Odtwórz z tego źródła",
"PIN_SOURCE": "Ustaw jako główne źródło",
"UNPIN_SOURCE": "Przestań używać jako główne źródło",
"CHECK": "sprawdź",
"CHECK_TOOLTIP": "Sprawdź dostępność",
"PROBE_OK": "dostępne",
"PROBE_FAIL": "niedostępne",
"PROBE_CHECKING": "sprawdzanie",
"AUTO_FAILOVER": "Automatyczna zmiana przy błędzie",
"PLAYING_FROM": "Odtwarzanie z:",
"SWITCH_TITLE": "Źródło niedostępne — przełączanie na {{playlist}}",
"SWITCH_DETAIL": "wznawianie od {{timecode}}",
"SWITCH_AUDIO_WARNING": "wersja językowa może się różnić",
"SWITCHED_TO": "Przełączono na {{playlist}}",
"UNDO": "Cofnij",
"CHOOSE_OTHER": "Wybierz inne",
"TRY_ANOTHER_SOURCE": "Spróbuj innego źródła",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} nie udostępnia tego strumienia. Ten film jest na Twoich innych listach odtwarzania:",
"RESUME_KEPT": "pozycja odtwarzania zostanie zachowana · {{timecode}}",
"ALL_SOURCES_TRIED": "Wypróbowano już wszystkie źródła.",
"ALSO_FOUND_IN_ONE": "— znaleziono także na jednej innej liście odtwarzania",
"ALSO_FOUND_IN_OTHER": "— znaleziono także na {{count}} innych listach odtwarzania",
"CHECK_ALL": "sprawdź wszystkie",
"FILTER_PLACEHOLDER": "Filtruj według listy odtwarzania",
"NO_MATCHES": "Nie znaleziono pasujących list odtwarzania",
"COPIES": "{{count}} kopii na tej liście",
"MORE_SOURCES": "+{{count}} na liście źródeł"
}
},
"INFORMATION": "Informacje",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: motor frame-copy (experimental)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "O vídeo é desenhado na tela da aplicação, para que menus e diálogos fiquem por cima.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Entra em vigor após reiniciar a aplicação.",
"VOD_AUTO_FAILOVER": "Trocar de fonte automaticamente ao falhar a reprodução",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Quando um filme não consegue ser reproduzido e o mesmo filme existe em outra playlist, troca para ela automaticamente e mantém a posição de reprodução. Desativado por padrão; a troca é sempre avisada, porque outra fonte pode ter uma dublagem diferente."
},
"THEMES": {
"DARK_THEME": "Escuro",
@@ -998,7 +1000,44 @@
"ITEM": "item",
"ITEMS": "itens",
"NO_EPISODES": "Nenhum episódio disponível",
"ALL_RADIO": "Todas as rádios"
"ALL_RADIO": "Todas as rádios",
"MULTI_SOURCE": {
"SOURCES": "Fontes",
"SOURCE": "Fonte",
"SOURCES_FOR": "Fontes para “{{title}}”",
"OPEN_SOURCES": "Mostrar fontes alternativas",
"MATCHED_BY_TMDB": "correspondência por TMDB",
"MATCHED_BY_TITLE": "correspondência por título",
"MATCH_BY_TITLE": "por título",
"PLAYING": "Reproduzindo",
"PLAY_FROM_SOURCE": "Reproduzir desta fonte",
"PIN_SOURCE": "Definir como fonte principal",
"UNPIN_SOURCE": "Remover como fonte principal",
"CHECK": "verificar",
"CHECK_TOOLTIP": "Verificar disponibilidade",
"PROBE_OK": "disponível",
"PROBE_FAIL": "indisponível",
"PROBE_CHECKING": "verificando",
"AUTO_FAILOVER": "Troca automática em caso de erro",
"PLAYING_FROM": "Reproduzindo de:",
"SWITCH_TITLE": "Fonte indisponível — trocando para {{playlist}}",
"SWITCH_DETAIL": "retomando em {{timecode}}",
"SWITCH_AUDIO_WARNING": "a dublagem pode ser diferente",
"SWITCHED_TO": "Trocado para {{playlist}}",
"UNDO": "Desfazer",
"CHOOSE_OTHER": "Escolher outra",
"TRY_ANOTHER_SOURCE": "Tentar outra fonte",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} não está fornecendo este stream. Este filme está nas suas outras playlists:",
"RESUME_KEPT": "a posição será mantida · {{timecode}}",
"ALL_SOURCES_TRIED": "Todas as fontes já foram testadas.",
"ALSO_FOUND_IN_ONE": "— também encontrado em outra playlist",
"ALSO_FOUND_IN_OTHER": "— também encontrado em outras {{count}} playlists",
"CHECK_ALL": "verificar todas",
"FILTER_PLACEHOLDER": "Filtrar por playlist",
"NO_MATCHES": "Nenhuma playlist corresponde a este filtro",
"COPIES": "{{count}} cópias nesta playlist",
"MORE_SOURCES": "+{{count}} na lista de fontes"
}
},
"INFORMATION": "Informação",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: движок frame-copy (экспериментально)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Видео рисуется на канвасе приложения, поэтому меню и диалоги отображаются поверх него.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Вступит в силу после перезапуска приложения.",
"VOD_AUTO_FAILOVER": "Авто-переключение источника при ошибке воспроизведения",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Если фильм не воспроизводится, а тот же фильм есть в другом плейлисте, автоматически переключаться на него, сохраняя таймкод. По умолчанию выключено; о переключении всегда сообщается, так как в другом источнике может быть другая озвучка."
},
"THEMES": {
"DARK_THEME": "Тёмная",
@@ -998,7 +1000,44 @@
"ITEM": "элемент",
"ITEMS": "элементов",
"NO_EPISODES": "Нет доступных эпизодов",
"ALL_RADIO": "Все радио"
"ALL_RADIO": "Все радио",
"MULTI_SOURCE": {
"SOURCES": "Источники",
"SOURCE": "Источник",
"SOURCES_FOR": "Источники для «{{title}}»",
"OPEN_SOURCES": "Показать альтернативные источники",
"MATCHED_BY_TMDB": "сопоставлено по TMDB",
"MATCHED_BY_TITLE": "сопоставлено по названию",
"MATCH_BY_TITLE": "по названию",
"PLAYING": "Играет",
"PLAY_FROM_SOURCE": "Воспроизвести из этого источника",
"PIN_SOURCE": "Сделать основным источником",
"UNPIN_SOURCE": "Отменить основной источник",
"CHECK": "проверить",
"CHECK_TOOLTIP": "Проверить доступность",
"PROBE_OK": "доступен",
"PROBE_FAIL": "недоступен",
"PROBE_CHECKING": "проверка",
"AUTO_FAILOVER": "Авто-переключение при ошибке",
"PLAYING_FROM": "Играет с:",
"SWITCH_TITLE": "Источник недоступен — переключаемся на {{playlist}}",
"SWITCH_DETAIL": "продолжаем с {{timecode}}",
"SWITCH_AUDIO_WARNING": "озвучка может отличаться",
"SWITCHED_TO": "Переключено на {{playlist}}",
"UNDO": "Отменить",
"CHOOSE_OTHER": "Выбрать другой",
"TRY_ANOTHER_SOURCE": "Попробовать другой источник",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} не отдаёт этот поток. Этот фильм есть в других ваших плейлистах:",
"RESUME_KEPT": "таймкод сохранится · {{timecode}}",
"ALL_SOURCES_TRIED": "Испробованы все источники.",
"ALSO_FOUND_IN_ONE": "— найден ещё в 1 плейлисте",
"ALSO_FOUND_IN_OTHER": "— найден ещё в {{count}} плейлистах",
"CHECK_ALL": "проверить всё",
"FILTER_PLACEHOLDER": "Фильтровать по плейлисту",
"NO_MATCHES": "Ни один плейлист не соответствует фильтру",
"COPIES": "Копий в этом плейлисте: {{count}}",
"MORE_SOURCES": "+{{count}} в списке источников"
}
},
"INFORMATION": "Инфо",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "Embedded MPV: frame-copy motoru (deneysel)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "Video uygulama tuvaline çizilir, böylece menüler ve iletişim kutuları üzerinde görüntülenir.",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur."
"EMBEDDED_MPV_FRAME_COPY_RESTART": "Uygulama yeniden başlatıldıktan sonra geçerli olur.",
"VOD_AUTO_FAILOVER": "Oynatma hatasında kaynağı otomatik değiştir",
"VOD_AUTO_FAILOVER_DESCRIPTION": "Bir film oynatılamadığında ve aynı film başka bir oynatma listesinde de varsa, otomatik olarak o listeye geçilir ve oynatma konumu korunur. Varsayılan olarak kapalıdır; başka bir kaynakta dublaj farklı olabileceği için geçiş her zaman bildirilir."
},
"THEMES": {
"DARK_THEME": "Koyu",
@@ -998,7 +1000,44 @@
"ITEM": "öğe",
"ITEMS": "öğe",
"NO_EPISODES": "Bölüm mevcut değil",
"ALL_RADIO": "Tüm radyolar"
"ALL_RADIO": "Tüm radyolar",
"MULTI_SOURCE": {
"SOURCES": "Kaynaklar",
"SOURCE": "Kaynak",
"SOURCES_FOR": "“{{title}}” için kaynaklar",
"OPEN_SOURCES": "Alternatif kaynakları göster",
"MATCHED_BY_TMDB": "TMDB ile eşleştirildi",
"MATCHED_BY_TITLE": "Başlığa göre eşleştirildi",
"MATCH_BY_TITLE": "başlığa göre",
"PLAYING": "Oynatılıyor",
"PLAY_FROM_SOURCE": "Bu kaynaktan oynat",
"PIN_SOURCE": "Bunu ana kaynak yap",
"UNPIN_SOURCE": "Bu kaynağın sabitlemesini kaldır",
"CHECK": "kontrol et",
"CHECK_TOOLTIP": "Kullanılabilirliği kontrol et",
"PROBE_OK": "kullanılabilir",
"PROBE_FAIL": "kullanılamıyor",
"PROBE_CHECKING": "kontrol ediliyor",
"AUTO_FAILOVER": "Hatada otomatik geçiş",
"PLAYING_FROM": "Şuradan oynatılıyor:",
"SWITCH_TITLE": "Kaynak kullanılamıyor — {{playlist}} kaynağına geçiliyor",
"SWITCH_DETAIL": "{{timecode}} konumundan devam ediliyor",
"SWITCH_AUDIO_WARNING": "dublaj farklı olabilir",
"SWITCHED_TO": "{{playlist}} kaynağına geçildi",
"UNDO": "Geri al",
"CHOOSE_OTHER": "Başkasını seç",
"TRY_ANOTHER_SOURCE": "Başka bir kaynak dene",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} bu yayını sunmuyor. Bu film diğer oynatma listelerinizde de var:",
"RESUME_KEPT": "oynatma konumu korunacak · {{timecode}}",
"ALL_SOURCES_TRIED": "Tüm kaynaklar denendi.",
"ALSO_FOUND_IN_ONE": "— bir oynatma listesinde daha bulundu",
"ALSO_FOUND_IN_OTHER": "— {{count}} oynatma listesinde daha bulundu",
"CHECK_ALL": "tümünü kontrol et",
"FILTER_PLACEHOLDER": "Oynatma listesine göre filtrele",
"NO_MATCHES": "Bu filtreyle eşleşen oynatma listesi yok",
"COPIES": "Bu oynatma listesinde {{count}} kopya",
"MORE_SOURCES": "Kaynak listesinde +{{count}} daha"
}
},
"INFORMATION": "Bilgi",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "嵌入式 MPV:帧拷贝引擎(实验性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "视频渲染到应用画布上,菜单和对话框可以显示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重启应用后生效。",
"VOD_AUTO_FAILOVER": "播放出错时自动切换播放源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "当某部电影无法播放,而其他播放列表中也有同一部影片时,自动切换过去并保留播放进度。默认关闭;由于其他播放源的配音可能不同,每次切换都会给出提示。"
},
"THEMES": {
"DARK_THEME": "深色",
@@ -998,7 +1000,44 @@
"ITEM": "项",
"ITEMS": "项",
"NO_EPISODES": "没有可用的剧集",
"ALL_RADIO": "所有广播"
"ALL_RADIO": "所有广播",
"MULTI_SOURCE": {
"SOURCES": "播放源",
"SOURCE": "播放源",
"SOURCES_FOR": "“{{title}}”的播放源",
"OPEN_SOURCES": "显示其他播放源",
"MATCHED_BY_TMDB": "通过 TMDB 匹配",
"MATCHED_BY_TITLE": "通过标题匹配",
"MATCH_BY_TITLE": "标题匹配",
"PLAYING": "正在播放",
"PLAY_FROM_SOURCE": "从此播放源播放",
"PIN_SOURCE": "设为主播放源",
"UNPIN_SOURCE": "取消主播放源",
"CHECK": "检测",
"CHECK_TOOLTIP": "检测可用性",
"PROBE_OK": "可用",
"PROBE_FAIL": "不可用",
"PROBE_CHECKING": "检测中",
"AUTO_FAILOVER": "出错时自动切换",
"PLAYING_FROM": "播放来源:",
"SWITCH_TITLE": "播放源不可用 — 正在切换到 {{playlist}}",
"SWITCH_DETAIL": "从 {{timecode}} 继续播放",
"SWITCH_AUDIO_WARNING": "配音可能不同",
"SWITCHED_TO": "已切换到 {{playlist}}",
"UNDO": "撤销",
"CHOOSE_OTHER": "选择其他",
"TRY_ANOTHER_SOURCE": "尝试其他播放源",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此视频流。这部电影也在您的其他播放列表中:",
"RESUME_KEPT": "将保留播放进度 · {{timecode}}",
"ALL_SOURCES_TRIED": "所有播放源均已尝试过。",
"ALSO_FOUND_IN_ONE": "— 另有 1 个播放列表提供此影片",
"ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 个播放列表提供此影片",
"CHECK_ALL": "全部检测",
"FILTER_PLACEHOLDER": "按播放列表筛选",
"NO_MATCHES": "未找到匹配的播放列表",
"COPIES": "此播放列表中有 {{count}} 个副本",
"MORE_SOURCES": "其余 {{count}} 个在播放源列表中"
}
},
"INFORMATION": "信息",
"DOWNLOADS": {
+41 -2
View File
@@ -448,7 +448,9 @@
"APP_UPDATE_RELEASE_NOTES": "What's new",
"EMBEDDED_MPV_FRAME_COPY": "內嵌 MPV:幀複製引擎(實驗性)",
"EMBEDDED_MPV_FRAME_COPY_DESCRIPTION": "影片會繪製到應用程式畫布上,選單和對話框可顯示在其上方。",
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。"
"EMBEDDED_MPV_FRAME_COPY_RESTART": "重新啟動應用程式後生效。",
"VOD_AUTO_FAILOVER": "播放發生錯誤時自動切換來源",
"VOD_AUTO_FAILOVER_DESCRIPTION": "當電影無法播放,且其他播放清單中也有同一部影片時,會自動切換過去並保留播放進度。預設為關閉;由於其他來源可能使用不同的配音,每次切換都會提示您。"
},
"THEMES": {
"DARK_THEME": "深色",
@@ -998,7 +1000,44 @@
"ITEM": "項",
"ITEMS": "項",
"NO_EPISODES": "沒有可用的集數",
"ALL_RADIO": "所有廣播"
"ALL_RADIO": "所有廣播",
"MULTI_SOURCE": {
"SOURCES": "播放來源",
"SOURCE": "播放來源",
"SOURCES_FOR": "「{{title}}」的播放來源",
"OPEN_SOURCES": "顯示其他播放來源",
"MATCHED_BY_TMDB": "依 TMDB 比對",
"MATCHED_BY_TITLE": "依標題比對",
"MATCH_BY_TITLE": "依標題",
"PLAYING": "播放中",
"PLAY_FROM_SOURCE": "從此來源播放",
"PIN_SOURCE": "設為主要來源",
"UNPIN_SOURCE": "取消設為主要來源",
"CHECK": "檢查",
"CHECK_TOOLTIP": "檢查是否可用",
"PROBE_OK": "可用",
"PROBE_FAIL": "無法使用",
"PROBE_CHECKING": "檢查中",
"AUTO_FAILOVER": "發生錯誤時自動切換",
"PLAYING_FROM": "播放來源:",
"SWITCH_TITLE": "來源無法使用 — 正在切換至 {{playlist}}",
"SWITCH_DETAIL": "從 {{timecode}} 繼續播放",
"SWITCH_AUDIO_WARNING": "配音可能不同",
"SWITCHED_TO": "已切換至 {{playlist}}",
"UNDO": "復原",
"CHOOSE_OTHER": "選擇其他來源",
"TRY_ANOTHER_SOURCE": "嘗試其他播放來源",
"ERROR_HAS_ALTERNATIVES": "{{playlist}} 未提供此串流。您的其他播放清單中有這部電影:",
"RESUME_KEPT": "將保留播放進度 · {{timecode}}",
"ALL_SOURCES_TRIED": "已嘗試所有播放來源。",
"ALSO_FOUND_IN_ONE": "— 另有 1 個播放清單也有此片",
"ALSO_FOUND_IN_OTHER": "— 另有 {{count}} 個播放清單也有此片",
"CHECK_ALL": "全部檢查",
"FILTER_PLACEHOLDER": "依播放清單篩選",
"NO_MATCHES": "沒有符合篩選條件的播放清單",
"COPIES": "此播放清單中的 {{count}} 個版本",
"MORE_SOURCES": "播放來源清單中另有 {{count}} 個"
}
},
"INFORMATION": "資訊",
"DOWNLOADS": {
+6
View File
@@ -36,11 +36,17 @@ pnpm run serve:marketing-demo:web
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
`multisrc1` and `multisrc2` deliberately share one faker seed, so both portals
generate an identical catalog. That overlap is what the VOD multi-source E2E
needs — the same movie present in two different playlists.
---
## API Endpoints
@@ -107,6 +107,31 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
vodDetailsFixture: 'empty-metadata',
},
// Two distinct credential pairs sharing one seed, so both portals generate
// an IDENTICAL catalog. That overlap is what VOD multi-source discovery
// needs: the same movie present in two different playlists.
'multisrc1:multisrc1': {
name: 'multi-source-a',
description: 'Multi-source portal A — shares its catalog with portal B',
seed: 4242,
categoryCount: { live: 1, vod: 2, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 2,
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'multisrc2:multisrc2': {
name: 'multi-source-b',
description: 'Multi-source portal B — shares its catalog with portal A',
seed: 4242,
categoryCount: { live: 1, vod: 2, series: 1 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 2,
accountStatus: 'Active',
expiryDate: '2099-12-31',
},
'marketing:marketing': {
name: 'marketing-demo',
description:
+19 -8
View File
@@ -51,15 +51,26 @@ These tokens are the base for interactive emphasis:
- `--app-selection-border`
- `--app-selection-glow`
Use Material surface tokens for neutral surfaces:
Use the app's own surface tokens for neutral surfaces (defined for both themes
in `apps/web/src/m3-theme.scss`):
- `--mat-sys-surface`
- `--mat-sys-surface-container-low`
- `--mat-sys-surface-container`
- `--mat-sys-surface-container-high`
- `--mat-sys-outline-variant`
- `--mat-sys-on-surface`
- `--mat-sys-on-surface-variant`
- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg`
- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers
- `--app-card-hover-bg` — raised or hovered rows
- `--app-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
**Do not reach for `--mat-sys-*` surface tokens.** Angular Material's system
tokens are *not* emitted globally in this app — only `--mat-sys-on-surface`
exists. `var(--mat-sys-surface-container-high)` and friends resolve to nothing,
which usually goes unnoticed because the surrounding Material component
supplies its own default and masks it. Outside a Material component — a CDK
overlay, a projected panel — the same reference silently produces a fully
transparent, unreadable surface.
If a `--mat-sys-*` token is genuinely needed, always give it a real fallback:
`var(--mat-sys-on-primary, #fff)`.
Do not hardcode unrelated accent colors for selected state when these tokens already exist.
+1
View File
@@ -106,6 +106,7 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
| --------------------- | ----------------------------------------------- |
| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` |
| Electron flows | `pnpm nx run electron-backend-e2e:e2e` |
| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` |
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
+185
View File
@@ -0,0 +1,185 @@
# VOD Multi-Source
Finds the same movie in the user's other imported playlists, lets them switch
which one it streams from without losing the timecode, and turns the
playback-error screen into a recovery point.
The point is not choice for its own sake — it is rescuing a viewing when the
current source is dead, serves an unsupported codec, or buffers badly.
## Scope (v1)
| | |
|---|---|
| Source types | **Xtream ↔ Xtream only** |
| Content | Movies only — series are not offered a source chip |
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`) |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency. **No codec probing** |
Stalker never reaches the `content` table (it would need a live authenticated
`get_ordered_list&search=` per portal), and M3U playlists are stored as a JSON
blob whose search path forces `content_type: 'live'`. Both are additive later
without changing the contracts — `VodSourceCandidate.portalType` already carries
`'xtream' | 'stalker' | 'm3u'` and discovery sits behind a service interface.
`ffprobe`/`ffmpeg` are not dependencies of this app and are not bundled, so
`provenance: 'probe'` means reachability and latency only. There is deliberately
no feature flag for codec probing: it would gate a code path with no binary
behind it.
## The honesty rule
This is the part to preserve if anything here is refactored.
Every metadata value carries **where it came from**:
| provenance | produced by | rendered as |
|---|---|---|
| `api` | `get_vod_info` — container, codec, audio, dimensions | plain tag |
| `parsed` | regex over the title/filename | tag prefixed `~`, warn colour |
| `probe` | HEAD → reachable + latency | `ok` / `fail` status tag |
| *absent* | — | **no tag at all** + a `check` chip |
Three rules follow, and each is enforced in code rather than by convention:
1. **A guess never ranks.** `factualOnly()`
(`libs/portal/shared/data-access/.../vod-source-metadata.util.ts`) is the only
sanctioned accessor for ranking and failover decisions, and `parsed` values
are structurally unreachable through it. A filename claiming 4K cannot
outrank a source that was actually reached.
2. **Unknown is not "unavailable".** `VodSourceProbeStatus` separates `fail`
(contacted and refused) from `unknown` (timed out, blocked by the redirect
policy, or no probe capability). A probe returning HTTP status `0` maps to
`unknown`; the UI keeps offering a check and never shows the source as dead.
3. **Empty beats wrong.** Quality is derived from pixel *width*, because
letterboxed masters are cropped vertically — a 2.39:1 1080p film is 1920×800,
and 800 alone is indistinguishable from a 1280×800 encode. With no width, a
height is trusted only within 5% of a standard frame height; otherwise no tag
is emitted.
Provenance is per-field and changes over time: at discovery a row has only
`parsed` tags, because the `content` table stores no container, codec or audio.
Facts arrive when the source is resolved.
## Architecture
```
VOD details page (Xtream)
└── VodMultiSourceHostService component-provided, one per open movie
├── VodMultiSourceController session state + failover ranking
├── VodSourceDiscoveryService ──► DB_FIND_TITLE_SOURCES (trigram FTS)
├── VodSourceResolverService ──► foreign playlist creds → get_vod_info
│ → constructVodUrl
├── VodSourcePinService ──► DB_GET/SET/CLEAR_VOD_SOURCE_PIN
└── StreamProbeService ──► STREAM_PROBE_URL (main process)
```
`VodMultiSourceHostService` is **component-provided, never root-provided**: the
controller's "each source is tried at most once" set must die with the movie, or
a later film inherits a poisoned failover history.
`VodMultiSourceController` is a plain class, not a service, for the same reason —
and so its ranking logic is testable without TestBed.
### Ownership
| Concern | Location |
|---|---|
| DTOs, match key | `libs/shared/interfaces/src/lib/vod-source*.ts` |
| Pin table | `libs/shared/database/src/lib/vod-source-pins.schema.ts` |
| Discovery SQL | `apps/electron-backend/.../operations/title-sources.operations.ts` |
| Pin CRUD | `apps/electron-backend/.../operations/vod-source-pin.operations.ts` |
| Probe handler | `apps/electron-backend/src/app/events/stream-probe.ts` |
| Discovery / resolve / rank | `libs/portal/shared/data-access/src/lib/multi-source/` |
| Probe + pin clients | `libs/services/src/lib/{stream-probe,vod-source-pin}.service.ts` |
| Row / popover / chip | `libs/ui/components/src/lib/vod-sources/` |
| Page wiring | `libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-*.ts` |
## Identity
Provider ids cannot key a pin — the same film has a different `stream_id` in
every portal, which is the problem being solved.
`buildVodSourceMatchKey()` produces `tmdb:{id}` when a usable TMDB id exists,
otherwise `title:{normalizedBase}:{year}` via the shared `normalizeTitleKeys`.
Lookups pass **every** alias, most-trusted first
(`buildVodSourceMatchKeyCandidates`). A movie pinned before TMDB enrichment
landed is stored under its title key and prefers a `tmdb:` key afterwards;
reading both means the id arriving later does not orphan the pin, and unpinning
clears every alias so a stale row cannot resurrect it.
## Why resolution is lazy
The `content` table stores no `container_extension`, and
`XtreamUrlService.constructVodUrl` returns `''` without one. A discovered source
is therefore **not playable from the database** — every alternative costs one
live `get_vod_info` against a foreign playlist's credentials, which can fail
offline, on an expired account, or behind Cloudflare.
So: rows render instantly from the FTS match with `parsed` provenance, and a URL
is resolved only when the user clicks play, pins, or checks. Containers are
memoised per `(playlistId, streamId)` for the session, and a container learned
earlier is reused if the portal later goes unreachable. Resolution is never
fanned out on page load.
## Switching without losing the timecode
Every playback host renders `@if (inlinePlayback(); as playback)`, so re-`set()`ing
that signal with a new `{streamUrl, startTime}` swaps the source **in place** —
the player component, `WebPlayerView` and the engine all survive, and
`WebPlayerViewComponent`'s effect rebuilds the source and clears the diagnostic.
Three details make the position survive:
- The carried position is the **live** one. `handleInlineTimeUpdate` reports to
`VodMultiSourceHostService.reportPosition()` *before* the 15-second
persistence throttle, so a switch does not rewind by up to 15 seconds.
- It is a single `.set()`, never `null` then set — a null in between would
destroy the player subtree and lose the engine.
- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so
a switch changes the key. The resolved playback carries the **new** source's
`contentInfo`, and that source's row takes over.
A resuming engine can emit a `timeupdate` at ~0 before it finishes seeking.
`VodDetailsPlaybackService` guards this with a one-shot `resumeSettled` latch —
a filter would have broken deliberate seek-backwards.
## Failover
Only fires when `Settings.vodAutoFailover` is on. Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
2. probed reachable; probed failing is penalised
3. not known to have failed recently
4. richer **factual** metadata (via `factualOnly`)
5. exact title match over fuzzy
Termination is structural: `triedSourceIds` only ever grows within a session, so
an N-source movie fails over at most N−1 times and then shows the honest error
screen. Returning to an earlier source by hand does not clear the set.
A source that cannot even be resolved is marked tried without becoming active,
so it is not re-picked.
The switch is **always announced** — another source can carry a different dub or
cut. The toast offers Undo, and adds a dub warning when
`audioDiffersFactually()` is true, which requires **both** sides to state an
audio track as fact. Two guesses, or a guess against a fact, stay silent.
Web engines only (HTML5/hls.js, Video.js, ArtPlayer). Embedded MPV suppresses
shared diagnostics and owns its own error block; external MPV/VLC are
fire-and-forget with no error channel back.
## PWA
Discovery, foreign-playlist reads, the pin table and the probe are all
main-process. A browser HEAD to an arbitrary IPTV host is CORS-blocked, and
`no-cors` yields an opaque response where 200, 403 and 404 are
indistinguishable — so the PWA cannot answer these questions honestly rather
than merely lacking a convenience.
Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching
`CatalogTitleMatchService`. In the PWA the chip renders nothing and the
auto-failover setting is hidden.
@@ -1 +1,2 @@
export * from './lib/collection';
export * from './lib/multi-source';
@@ -0,0 +1,4 @@
export * from './vod-multi-source.controller';
export * from './vod-source-discovery.service';
export * from './vod-source-metadata.util';
export * from './vod-source-resolver.service';
@@ -0,0 +1,236 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceController } from './vod-multi-source.controller';
function source(
id: string,
overrides: Partial<VodSourceCandidate> = {}
): VodSourceCandidate {
return {
id,
playlistId: id,
playlistName: id.toUpperCase(),
portalType: 'xtream',
contentId: 1,
rawTitle: 'Dune',
matchConfidence: 'exact',
...overrides,
};
}
function controllerWith(...ids: string[]) {
const controller = new VodMultiSourceController();
controller.setSources(ids.map((id) => source(id)), 'title-year');
return controller;
}
describe('VodMultiSourceController', () => {
describe('alternatives and the chip', () => {
it('reports no alternatives when the only source is the active one', () => {
const controller = controllerWith('a');
controller.setActiveSource('a');
expect(controller.hasAlternatives()).toBe(false);
expect(controller.alternativeCount()).toBe(0);
});
it('excludes the active source from the alternatives', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
expect(controller.alternatives().map((s) => s.id)).toEqual([
'b',
'c',
]);
});
});
describe('failover cannot loop', () => {
it('never returns a source that was already tried', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
const first = controller.pickFailoverTarget();
expect(first).not.toBeNull();
controller.setActiveSource(first!.id);
const second = controller.pickFailoverTarget();
expect(second!.id).not.toBe(first!.id);
expect(second!.id).not.toBe('a');
});
it('exhausts after each source has been tried exactly once', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
const visited = ['a'];
for (let hop = 0; hop < 5; hop++) {
const next = controller.pickFailoverTarget();
if (!next) {
break;
}
expect(visited).not.toContain(next.id);
visited.push(next.id);
controller.setActiveSource(next.id);
}
// Three sources, three visits, then an honest dead end.
expect(visited).toEqual(['a', 'b', 'c']);
expect(controller.pickFailoverTarget()).toBeNull();
expect(controller.isExhausted()).toBe(true);
});
it('stays exhausted even if the user returns to an earlier source', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.setActiveSource('b');
controller.setActiveSource('a');
// Returning by hand is allowed; automatic retrying is not.
expect(controller.pickFailoverTarget()).toBeNull();
});
});
describe('failover ranking', () => {
it('prefers a probed-reachable source', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.setProbe('c', { status: 'ok' });
expect(controller.pickFailoverTarget()!.id).toBe('c');
});
it('avoids a source probed as failing', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.setProbe('b', { status: 'fail' });
expect(controller.pickFailoverTarget()!.id).toBe('c');
});
it('treats an unknown probe as neutral, not as a failure', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[source('a'), source('b'), source('c')],
'title-year'
);
controller.setActiveSource('a');
controller.setProbe('b', { status: 'unknown' });
controller.setProbe('c', { status: 'fail' });
// 'unknown' means we could not check — it must not be ranked
// below a source we positively know is broken.
expect(controller.pickFailoverTarget()!.id).toBe('b');
});
it('ignores guessed metadata when ranking', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
// Rich-looking, but every value is a filename guess.
source('guessy', {
quality: { value: '2160p', provenance: 'parsed' },
codec: { value: 'HEVC', provenance: 'parsed' },
audio: { value: 'Дубляж', provenance: 'parsed' },
container: { value: 'mkv', provenance: 'parsed' },
}),
// One verified fact beats four guesses.
source('factual', {
container: { value: 'mp4', provenance: 'api' },
}),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('factual');
});
it('prefers an exact match over a fuzzy one, all else equal', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
source('fuzzy', { matchConfidence: 'fuzzy' }),
source('exact', { matchConfidence: 'exact' }),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('exact');
});
it('deprioritizes a source known to have failed before', () => {
const controller = new VodMultiSourceController();
controller.setSources(
[
source('a'),
source('stale', {
lastFailedAt: '2026-07-01T00:00:00.000Z',
}),
source('fresh'),
],
'title-year'
);
controller.setActiveSource('a');
expect(controller.pickFailoverTarget()!.id).toBe('fresh');
});
});
describe('resume position', () => {
it('carries the last reported position', () => {
const controller = controllerWith('a');
controller.setResumeSeconds(2538);
expect(controller.getResumeSeconds()).toBe(2538);
});
it('ignores nonsensical positions rather than losing the good one', () => {
const controller = controllerWith('a');
controller.setResumeSeconds(2538);
controller.setResumeSeconds(NaN);
controller.setResumeSeconds(-5);
controller.setResumeSeconds(Infinity);
expect(controller.getResumeSeconds()).toBe(2538);
});
});
describe('source state', () => {
it('marks the pinned and active sources', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.setPinnedSource('b');
const [first, second] = controller.sources();
expect(first.isActive).toBe(true);
expect(first.isPinned).toBe(false);
expect(second.isActive).toBe(false);
expect(second.isPinned).toBe(true);
});
it('merges upgraded metadata without reordering the list', () => {
const controller = controllerWith('a', 'b');
controller.updateSource(
source('b', {
container: { value: 'mp4', provenance: 'api' },
})
);
expect(controller.sources().map((s) => s.id)).toEqual(['a', 'b']);
expect(controller.sources()[1].container).toEqual({
value: 'mp4',
provenance: 'api',
});
});
it('defaults every source to an idle probe', () => {
const controller = controllerWith('a');
expect(controller.sources()[0].probe).toEqual({ status: 'idle' });
});
});
});
@@ -0,0 +1,183 @@
import { computed, signal } from '@angular/core';
import type {
VodSourceCandidate,
VodSourceDescriptor,
VodSourceMatchKind,
VodSourceProbeResult,
} from '@iptvnator/shared/interfaces';
import { factualOnly } from './vod-source-metadata.util';
/**
* Per-movie multi-source session state.
*
* A plain class rather than a service: one instance belongs to one open movie,
* mirroring `StalkerVodPlaybackController`. Holding this in a root-provided
* service would leak `triedSourceIds` between films and quietly break the
* anti-loop guarantee.
*
* It owns state and decisions only — no I/O. Discovery, resolution, probing
* and persistence are injected by the host, which keeps this testable without
* TestBed.
*/
export class VodMultiSourceController {
private readonly _sources = signal<VodSourceCandidate[]>([]);
private readonly _activeSourceId = signal<string | null>(null);
private readonly _pinnedSourceId = signal<string | null>(null);
private readonly _matchKind = signal<VodSourceMatchKind>('title-year');
private readonly _probes = signal<Record<string, VodSourceProbeResult>>({});
/**
* Sources already attempted in this session.
*
* The anti-loop guarantee: failover only ever considers sources absent
* from this set, so a three-source movie can fail over at most twice and
* then honestly gives up. Never cleared during a session — only a fresh
* movie (a new controller) resets it.
*/
private readonly tried = new Set<string>();
/** Live playback position, carried across a source switch. */
private resumeSeconds = 0;
readonly matchKind = this._matchKind.asReadonly();
readonly activeSourceId = this._activeSourceId.asReadonly();
/** Every known source, decorated with live session state. */
readonly sources = computed<VodSourceDescriptor[]>(() => {
const activeId = this._activeSourceId();
const pinnedId = this._pinnedSourceId();
const probes = this._probes();
return this._sources().map((candidate) => ({
...candidate,
isActive: candidate.id === activeId,
isPinned: candidate.id === pinnedId,
isTried: this.tried.has(candidate.id),
probe: probes[candidate.id] ?? { status: 'idle' },
}));
});
/** Sources other than the one playing — what the popover offers. */
readonly alternatives = computed(() =>
this.sources().filter((source) => !source.isActive)
);
/** The chip only appears when there is genuinely somewhere else to go. */
readonly hasAlternatives = computed(() => this.alternatives().length > 0);
readonly alternativeCount = computed(() => this.alternatives().length);
setSources(sources: VodSourceCandidate[], matchKind: VodSourceMatchKind) {
this._sources.set(sources);
this._matchKind.set(matchKind);
}
/**
* Merge an updated candidate (e.g. after resolution upgraded its metadata
* from `parsed` to `api`) without disturbing list order or identity.
*/
updateSource(updated: VodSourceCandidate) {
this._sources.update((sources) =>
sources.map((source) =>
source.id === updated.id ? updated : source
)
);
}
setActiveSource(sourceId: string | null) {
this._activeSourceId.set(sourceId);
if (sourceId) {
this.tried.add(sourceId);
}
}
/**
* Record an attempt that never became active — e.g. a source whose URL
* could not be resolved at all. Without this, failover would keep
* re-picking a source that already proved unusable and spin.
*/
markTried(sourceId: string) {
this.tried.add(sourceId);
// `tried` is not a signal (it is per-session bookkeeping, not state
// the view derives from directly), so nudge the derived list.
this._sources.update((sources) => [...sources]);
}
setPinnedSource(sourceId: string | null) {
this._pinnedSourceId.set(sourceId);
}
setProbe(sourceId: string, result: VodSourceProbeResult) {
this._probes.update((probes) => ({ ...probes, [sourceId]: result }));
}
/** Position updates arrive continuously from the player. */
setResumeSeconds(seconds: number) {
if (Number.isFinite(seconds) && seconds >= 0) {
this.resumeSeconds = seconds;
}
}
getResumeSeconds(): number {
return this.resumeSeconds;
}
findSource(sourceId: string): VodSourceCandidate | null {
return (
this._sources().find((source) => source.id === sourceId) ?? null
);
}
/**
* The best source to fail over to, or null when the options are exhausted.
*
* Ranking deliberately ignores guessed metadata — a filename claiming 4K
* must not outrank a source we actually reached. Order:
* 1. never tried this session (a hard filter, not a preference)
* 2. probed reachable
* 3. not known to have failed recently
* 4. richer FACTUAL metadata
* 5. exact title match over fuzzy
*/
pickFailoverTarget(): VodSourceDescriptor | null {
const untried = this.sources().filter(
(source) => !source.isActive && !source.isTried
);
if (untried.length === 0) {
return null;
}
return [...untried].sort((a, b) => score(b) - score(a))[0];
}
/** True once every source has been attempted — time for an honest error. */
isExhausted(): boolean {
return this.pickFailoverTarget() === null;
}
}
function score(source: VodSourceDescriptor): number {
let value = 0;
if (source.probe.status === 'ok') {
value += 100;
}
if (source.probe.status === 'fail') {
value -= 100;
}
if (source.lastFailedAt) {
value -= 50;
}
if (source.matchConfidence === 'exact') {
value += 10;
}
// Only verified metadata counts toward "richer". A parsed guess adds
// nothing here by construction — factualOnly() cannot return one.
const facts = factualOnly(source);
value += [facts.quality, facts.codec, facts.container, facts.audio].filter(
Boolean
).length;
return value;
}
@@ -0,0 +1,93 @@
import { Injectable } from '@angular/core';
import type {
VodSourceCandidate,
VodSourceCandidateRow,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { parseTitleMetadata } from './vod-source-metadata.util';
/**
* Finds the same movie in the user's other playlists.
*
* Electron-only, and deliberately lazy: it runs when a detail page opens, not
* as a background index. Discovery returns rows the `content` table can prove
* — title, ids, poster — and NOTHING about container, codec or audio, because
* those columns do not exist. Everything a row shows at this stage is either a
* hard id or a guess parsed off the title and marked as one.
*/
export interface VodSourceDiscoveryRequest {
title: string;
year?: number | null;
/** The playlist being viewed; never offered back as an alternative. */
currentPlaylistId: string;
}
export interface VodSourceDiscoveryResult {
sources: VodSourceCandidate[];
/**
* How the set was matched. v1 matches on normalized title + year — a TMDB
* id is not stored in the `content` table, so it cannot drive the SQL.
*/
matchKind: VodSourceMatchKind;
}
@Injectable({ providedIn: 'root' })
export class VodSourceDiscoveryService {
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbFindTitleSources === 'function'
);
}
async discover(
request: VodSourceDiscoveryRequest
): Promise<VodSourceDiscoveryResult> {
const empty: VodSourceDiscoveryResult = {
sources: [],
matchKind: 'title-year',
};
if (!this.isAvailable || !request.title?.trim()) {
return empty;
}
try {
const rows = await window.electron.dbFindTitleSources({
title: request.title,
year: request.year ?? null,
excludePlaylistId: request.currentPlaylistId,
});
return {
sources: (rows ?? []).map(toCandidate),
matchKind: 'title-year',
};
} catch (error) {
console.warn('VOD source discovery failed:', error);
return empty;
}
}
}
function toCandidate(row: VodSourceCandidateRow): VodSourceCandidate {
// Everything inferable at this point comes from the title string, so it is
// all `provenance: 'parsed'`. Facts arrive later, from `get_vod_info`.
const parsed = parseTitleMetadata(row.title);
return {
id: `${row.playlistId}:xtream:${row.xtreamId}`,
playlistId: row.playlistId,
playlistName: row.playlistName,
portalType: 'xtream',
contentId: row.xtreamId,
rawTitle: row.title,
matchConfidence: row.matchConfidence,
year: row.year,
posterUrl: row.posterUrl,
quality: parsed.quality,
codec: parsed.codec,
audio: parsed.audio,
};
}
@@ -0,0 +1,224 @@
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import {
applyApiMetadata,
audioDiffersFactually,
factualOnly,
parseTitleMetadata,
} from './vod-source-metadata.util';
function candidate(
overrides: Partial<VodSourceCandidate> = {}
): VodSourceCandidate {
return {
id: 'p1:xtream:1',
playlistId: 'p1',
playlistName: 'SharkTV',
portalType: 'xtream',
contentId: 1,
rawTitle: 'Dune',
matchConfidence: 'exact',
...overrides,
};
}
describe('parseTitleMetadata', () => {
it('returns nothing for an empty title rather than inventing defaults', () => {
expect(parseTitleMetadata('')).toEqual({});
expect(parseTitleMetadata(null)).toEqual({});
expect(parseTitleMetadata(undefined)).toEqual({});
});
it('leaves fields undefined when the title says nothing about them', () => {
const parsed = parseTitleMetadata('Dune Part Two');
expect(parsed.quality).toBeUndefined();
expect(parsed.codec).toBeUndefined();
expect(parsed.audio).toBeUndefined();
expect(parsed.hdr).toBeUndefined();
});
it('marks every inferred value as a guess', () => {
const parsed = parseTitleMetadata('Dune 2160p HEVC Дубляж HDR');
expect(parsed.quality).toEqual({
value: '2160p',
provenance: 'parsed',
});
expect(parsed.codec).toEqual({ value: 'HEVC', provenance: 'parsed' });
expect(parsed.audio).toEqual({ value: 'Дубляж', provenance: 'parsed' });
expect(parsed.hdr).toEqual({ value: 'HDR', provenance: 'parsed' });
});
it.each([
['Dune 4K', '2160p'],
['Dune UHD', '2160p'],
['Dune 1080i', '1080p'],
['Dune 720P', '720p'],
])('normalizes quality tag %s', (title, expected) => {
expect(parseTitleMetadata(title).quality?.value).toBe(expected);
});
it.each([
['Dune x265', 'HEVC'],
['Dune H.265', 'HEVC'],
['Dune x264', 'H.264'],
['Dune H264', 'H.264'],
['Dune AV1', 'AV1'],
])('normalizes codec tag %s', (title, expected) => {
expect(parseTitleMetadata(title).codec?.value).toBe(expected);
});
it('prefers the more specific quality when several could match', () => {
// "4K" and "1080p" both appear; the higher, more specific tag wins.
expect(parseTitleMetadata('Dune 4K remux from 1080p').quality?.value)
.toBe('2160p');
});
it('does not match quality tags embedded in unrelated words', () => {
expect(parseTitleMetadata('Blade Runner 2049').quality).toBeUndefined();
});
});
describe('applyApiMetadata', () => {
it('overrides a guess with a provider-stated fact', () => {
const withGuess = candidate({
container: { value: 'mkv', provenance: 'parsed' },
});
const result = applyApiMetadata(withGuess, {
containerExtension: 'mp4',
});
expect(result.container).toEqual({ value: 'mp4', provenance: 'api' });
});
it('keeps an existing guess when the provider states nothing', () => {
const withGuess = candidate({
quality: { value: '1080p', provenance: 'parsed' },
});
const result = applyApiMetadata(withGuess, {
containerExtension: null,
height: null,
});
// Erasing a marked guess in favour of nothing helps no one.
expect(result.quality).toEqual({
value: '1080p',
provenance: 'parsed',
});
});
it('derives quality from a standard frame height as a fact', () => {
expect(applyApiMetadata(candidate(), { height: 2160 }).quality).toEqual({
value: '2160p',
provenance: 'api',
});
});
it('prefers width, which cropping does not distort', () => {
// A 2.39:1 1080p master is 1920x800 — the width still says 1080p.
expect(
applyApiMetadata(candidate(), { width: 1920, height: 800 }).quality
).toEqual({ value: '1080p', provenance: 'api' });
});
it('emits no quality for an ambiguous cropped height', () => {
// 800 lines alone could be a cropped 1080p or a 1280x800 encode.
// Guessing here would publish a wrong value labelled as a fact.
expect(applyApiMetadata(candidate(), { height: 800 }).quality)
.toBeUndefined();
});
it('ignores a nonsensical dimension instead of emitting a bogus fact', () => {
expect(applyApiMetadata(candidate(), { height: 0 }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { height: -5 }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { height: NaN }).quality)
.toBeUndefined();
expect(applyApiMetadata(candidate(), { width: 0, height: 1080 }).quality)
.toEqual({ value: '1080p', provenance: 'api' });
});
it('normalizes provider codec names', () => {
expect(applyApiMetadata(candidate(), { videoCodec: 'hevc' }).codec)
.toEqual({ value: 'HEVC', provenance: 'api' });
expect(applyApiMetadata(candidate(), { videoCodec: 'h264' }).codec)
.toEqual({ value: 'H.264', provenance: 'api' });
});
it('does not mutate the input candidate', () => {
const original = candidate();
applyApiMetadata(original, { containerExtension: 'mp4' });
expect(original.container).toBeUndefined();
});
});
describe('factualOnly', () => {
it('hides guessed values from ranking decisions', () => {
const mixed = candidate({
quality: { value: '2160p', provenance: 'parsed' },
container: { value: 'mp4', provenance: 'api' },
});
const facts = factualOnly(mixed);
// The whole point: a filename claiming 4K must not outrank a source
// we actually verified.
expect(facts.quality).toBeUndefined();
expect(facts.container).toEqual({ value: 'mp4', provenance: 'api' });
});
it('keeps probe-sourced values', () => {
const probed = candidate({
codec: { value: 'HEVC', provenance: 'probe' },
});
expect(factualOnly(probed).codec).toEqual({
value: 'HEVC',
provenance: 'probe',
});
});
});
describe('audioDiffersFactually', () => {
it('warns only when both sides are known facts that differ', () => {
const from = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
const to = candidate({ audio: { value: 'Eng', provenance: 'api' } });
expect(audioDiffersFactually(from, to)).toBe(true);
});
it('stays silent when either side is only a guess', () => {
const from = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
const guessed = candidate({
audio: { value: 'MVO', provenance: 'parsed' },
});
expect(audioDiffersFactually(from, guessed)).toBe(false);
});
it('stays silent when either side is unknown', () => {
const known = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
expect(audioDiffersFactually(known, candidate())).toBe(false);
expect(audioDiffersFactually(candidate(), known)).toBe(false);
expect(audioDiffersFactually(null, known)).toBe(false);
});
it('stays silent when both sides state the same track', () => {
const from = candidate({
audio: { value: 'Дубляж', provenance: 'api' },
});
const to = candidate({ audio: { value: 'Дубляж', provenance: 'api' } });
expect(audioDiffersFactually(from, to)).toBe(false);
});
});
@@ -0,0 +1,272 @@
import type {
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
/**
* Metadata extraction for VOD multi-source rows.
*
* The governing rule: a value inferred from a filename is a GUESS and must
* never be presented, sorted, or acted on as though the provider had stated
* it. Everything here returns `provenance: 'parsed'`; facts come from
* `applyApiMetadata` below, and reachability from the probe.
*
* Empty beats wrong — when a pattern does not match, the field stays
* `undefined` and the row simply shows no tag, plus a "check" affordance.
*/
/**
* Unicode-aware word boundary.
*
* JavaScript's `\b` is ASCII-only, so `/\bдубляж\b/` never matches — which
* would silently drop the dub tag on exactly the Cyrillic catalogues where it
* matters most. Lookarounds over `\p{L}\p{N}` give a real boundary.
*/
function bounded(source: string): RegExp {
return new RegExp(`(?<![\\p{L}\\p{N}])(?:${source})(?![\\p{L}\\p{N}])`, 'iu');
}
/** Release-quality tags, most specific first so "4K" wins over "1080p". */
const QUALITY_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('2160p|4k|uhd'), '2160p'],
[bounded('1440p'), '1440p'],
[bounded('1080[pi]'), '1080p'],
[bounded('720[pi]'), '720p'],
[bounded('576[pi]'), '576p'],
[bounded('480[pi]'), '480p'],
];
const CODEC_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('hevc|h\\.?265|x265'), 'HEVC'],
[bounded('avc|h\\.?264|x264'), 'H.264'],
[bounded('av1'), 'AV1'],
[bounded('mpeg-?2'), 'MPEG-2'],
];
/**
* Dub/voice-over markers. The single most valuable field to a user and the
* least likely to be stated by any provider, so it is worth guessing — but
* only ever as a guess.
*/
const AUDIO_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('дубляж|dub'), 'Дубляж'],
[bounded('mvo'), 'MVO'],
[bounded('dvo'), 'DVO'],
[bounded('avo'), 'AVO'],
[bounded('субтитры|subs|sub'), 'Sub'],
[bounded('eng|english'), 'Eng'],
[bounded('rus|russian|рус'), 'Rus'],
[bounded('ukr|ukrainian|укр'), 'Ukr'],
];
const HDR_PATTERNS: ReadonlyArray<[RegExp, string]> = [
[bounded('dolby\\s?vision|dv'), 'Dolby Vision'],
[bounded('hdr10\\+'), 'HDR10+'],
[bounded('hdr'), 'HDR'],
];
function matchFirst(
raw: string,
patterns: ReadonlyArray<[RegExp, string]>
): VodSourceField | undefined {
for (const [pattern, value] of patterns) {
if (pattern.test(raw)) {
return { value, provenance: 'parsed' };
}
}
return undefined;
}
export interface ParsedTitleMetadata {
quality?: VodSourceField;
codec?: VodSourceField;
audio?: VodSourceField;
hdr?: VodSourceField;
}
/**
* Infer what the title string is willing to admit. Everything returned is a
* guess, tagged as such.
*/
export function parseTitleMetadata(
rawTitle: string | null | undefined
): ParsedTitleMetadata {
if (!rawTitle) {
return {};
}
return {
quality: matchFirst(rawTitle, QUALITY_PATTERNS),
codec: matchFirst(rawTitle, CODEC_PATTERNS),
audio: matchFirst(rawTitle, AUDIO_PATTERNS),
hdr: matchFirst(rawTitle, HDR_PATTERNS),
};
}
/** Metadata a provider actually stated, from `get_vod_info`. */
export interface ProviderVodMetadata {
containerExtension?: string | null;
videoCodec?: string | null;
audioCodec?: string | null;
/** Pixel width from the provider's own video stream info. */
width?: number | null;
/** Pixel height from the provider's own video stream info. */
height?: number | null;
}
/**
* Overlay provider-stated facts on top of parsed guesses.
*
* A fact always wins: once the portal says the container is `mkv`, the guess
* is discarded rather than shown alongside it. A missing fact never erases an
* existing guess — that would trade a marked guess for nothing.
*/
export function applyApiMetadata(
candidate: VodSourceCandidate,
api: ProviderVodMetadata
): VodSourceCandidate {
const next: VodSourceCandidate = { ...candidate };
const container = cleanString(api.containerExtension);
if (container) {
next.container = { value: container, provenance: 'api' };
}
const codec = normalizeCodecName(api.videoCodec);
if (codec) {
next.codec = { value: codec, provenance: 'api' };
}
const audio = normalizeCodecName(api.audioCodec);
if (audio) {
next.audio = { value: audio, provenance: 'api' };
}
const quality = qualityFromDimensions(api.width, api.height);
if (quality) {
next.quality = { value: quality, provenance: 'api' };
}
return next;
}
/**
* The only sanctioned way to read metadata for ranking or automatic choices.
*
* Guessed values are structurally unreachable from here, which is what stops
* "~2160p parsed off a filename" from outranking a source we actually
* verified. Presentation code reads the fields directly; decision code must
* come through this.
*/
export function factualOnly(
candidate: VodSourceCandidate
): Pick<VodSourceCandidate, 'quality' | 'codec' | 'container' | 'audio'> {
const keep = (field?: VodSourceField) =>
field && field.provenance !== 'parsed' ? field : undefined;
return {
quality: keep(candidate.quality),
codec: keep(candidate.codec),
container: keep(candidate.container),
audio: keep(candidate.audio),
};
}
/**
* True when both sides state an audio track AS FACT and those facts differ.
*
* Deliberately conservative: two guesses, or a guess against a fact, are not
* enough to claim the dub changed. The warning must be trustworthy, so it
* fires only when both sides are known — and stays silent when we simply do
* not know, which is the common case.
*/
export function audioDiffersFactually(
from: VodSourceCandidate | null | undefined,
to: VodSourceCandidate | null | undefined
): boolean {
const a = from && factualOnly(from).audio?.value;
const b = to && factualOnly(to).audio?.value;
return Boolean(a && b && a !== b);
}
function cleanString(raw: string | null | undefined): string | null {
const trimmed = typeof raw === 'string' ? raw.trim() : '';
return trimmed === '' ? null : trimmed;
}
/** Map ffmpeg-style codec names onto the labels users recognize. */
function normalizeCodecName(raw: string | null | undefined): string | null {
const value = cleanString(raw);
if (!value) {
return null;
}
const lower = value.toLowerCase();
if (lower === 'hevc' || lower === 'h265') {
return 'HEVC';
}
if (lower === 'h264' || lower === 'avc') {
return 'H.264';
}
return value;
}
/** Standard frame heights, for recognizing an uncropped master. */
const STANDARD_HEIGHTS: ReadonlyArray<[number, string]> = [
[2160, '2160p'],
[1440, '1440p'],
[1080, '1080p'],
[720, '720p'],
[576, '576p'],
[480, '480p'],
];
function isPositiveNumber(value: number | null | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
/**
* Derive the quality label from real pixel dimensions.
*
* Width is authoritative because letterboxed masters are cropped vertically:
* a 2.39:1 "1080p" film is 1920×800, and 800 alone is indistinguishable from
* a 1280×800 720p-class encode. Bucketing that by height would emit "720p"
* labelled as a FACT — precisely the kind of confident wrongness this feature
* exists to avoid.
*
* With no width, a height is only trusted when it is (near) a standard frame
* height. Anything else returns null, so the row shows no tag and offers a
* check instead. Empty beats wrong.
*/
function qualityFromDimensions(
width: number | null | undefined,
height: number | null | undefined
): string | null {
if (isPositiveNumber(width)) {
if (width >= 3400) {
return '2160p';
}
if (width >= 2400) {
return '1440p';
}
if (width >= 1700) {
return '1080p';
}
if (width >= 1200) {
return '720p';
}
if (width >= 900) {
return '576p';
}
return '480p';
}
if (!isPositiveNumber(height)) {
return null;
}
const standard = STANDARD_HEIGHTS.find(
([reference]) => Math.abs(height - reference) / reference <= 0.05
);
return standard ? standard[1] : null;
}
@@ -0,0 +1,219 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
XTREAM_DATA_SOURCE,
XtreamApiService,
XtreamUrlService,
type IXtreamDataSource,
type XtreamCredentials,
} from '@iptvnator/portal/xtream/data-access';
import type {
ResolvedPortalPlayback,
VodSourceCandidate,
} from '@iptvnator/shared/interfaces';
import { applyApiMetadata } from './vod-source-metadata.util';
/**
* Turns a discovered source into something playable.
*
* This exists because discovery alone is not enough: the `content` table has
* no `container_extension` column, and `constructVodUrl` returns '' without
* one. Every alternative therefore costs one live `get_vod_info` against a
* FOREIGN playlist's credentials — which is exactly why resolution is lazy and
* never fanned out on page load.
*
* That same call is also the first moment real facts about a source exist, so
* resolution doubles as the metadata upgrade from `parsed` to `api`.
*/
export interface ResolvedVodSource {
playback: ResolvedPortalPlayback;
/** The candidate with provider-stated facts merged in. */
candidate: VodSourceCandidate;
}
@Injectable({ providedIn: 'root' })
export class VodSourceResolverService {
/**
* The Xtream chain is resolved LAZILY, never at construction.
*
* This service is root-provided but its dependencies are route-scoped
* (`XTREAM_DATA_SOURCE` comes from `provideXtreamDataSource()`, and
* `XtreamApiService` pulls in `DataService`). Injecting them eagerly would
* make merely *providing* multi-source throw outside a fully configured
* Xtream route. Resolution only ever happens on an explicit user action —
* play, pin, or check — so deferring costs nothing and cannot explode.
*/
private readonly injector = inject(Injector);
private getXtreamDeps(): {
dataSource: IXtreamDataSource;
api: XtreamApiService;
urlService: XtreamUrlService;
} | null {
const dataSource = this.injector.get(XTREAM_DATA_SOURCE, null, {
optional: true,
});
if (!dataSource) {
return null;
}
return {
dataSource,
api: this.injector.get(XtreamApiService),
urlService: this.injector.get(XtreamUrlService),
};
}
/**
* Containers are immutable per (playlist, stream) and cost a network round
* trip to learn, so the second visit to a movie resolves instantly.
*/
private readonly containerCache = new Map<string, string>();
async resolve(
candidate: VodSourceCandidate,
options: { startTime?: number } = {}
): Promise<ResolvedVodSource | null> {
if (candidate.portalType !== 'xtream') {
// v1 discovers Xtream only; the guard keeps a future Stalker
// candidate from silently resolving to a broken Xtream URL.
return null;
}
const deps = this.getXtreamDeps();
if (!deps) {
return null;
}
const playlist = await this.loadPlaylist(
deps.dataSource,
candidate.playlistId
);
if (!playlist) {
return null;
}
const credentials: XtreamCredentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
allowedOutputFormats: playlist.allowedOutputFormats,
};
const details = await this.loadVodDetails(
deps.api,
credentials,
candidate
);
if (!details) {
return null;
}
const streamUrl = deps.urlService.constructVodUrl(
credentials,
details.vodDetails
);
if (!streamUrl) {
return null;
}
return {
playback: {
streamUrl,
title: candidate.rawTitle,
thumbnail: candidate.posterUrl ?? null,
isLive: false,
startTime: options.startTime,
contentInfo: {
// The NEW source owns the position from here on — the
// position key is (playlistId, contentXtreamId, type).
playlistId: candidate.playlistId,
contentXtreamId: candidate.contentId,
contentType: 'vod',
},
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
candidate: applyApiMetadata(candidate, details.metadata),
};
}
private async loadPlaylist(
dataSource: IXtreamDataSource,
playlistId: string
) {
try {
const playlist = await dataSource.getPlaylist(playlistId);
// A foreign playlist can be missing credentials entirely (an M3U
// row, or a partially imported portal) — resolving would build a
// nonsense URL.
if (!playlist?.serverUrl || !playlist.username) {
return null;
}
return playlist;
} catch (error) {
console.warn('Loading the alternative playlist failed:', error);
return null;
}
}
private async loadVodDetails(
api: XtreamApiService,
credentials: XtreamCredentials,
candidate: VodSourceCandidate
) {
const cacheKey = `${candidate.playlistId}:${candidate.contentId}`;
const cachedContainer = this.containerCache.get(cacheKey);
try {
const vodDetails = await api.getVodInfo(
credentials,
candidate.contentId
);
const info = Array.isArray(vodDetails?.info)
? undefined
: vodDetails?.info;
const video = info?.video as
| { codec_name?: string; width?: number; height?: number }
| undefined;
const audio = info?.audio as { codec_name?: string } | undefined;
const container = vodDetails?.movie_data?.container_extension;
if (container) {
this.containerCache.set(cacheKey, container);
}
return {
vodDetails,
metadata: {
containerExtension: container ?? null,
videoCodec: video?.codec_name ?? null,
audioCodec: audio?.codec_name ?? null,
width: video?.width ?? null,
height: video?.height ?? null,
},
};
} catch (error) {
console.warn('Reading alternative VOD details failed:', error);
// The portal is unreachable right now, but a container learned on
// a previous visit is still valid — enough to build a URL and let
// playback itself report the real problem.
if (!cachedContainer) {
return null;
}
return {
vodDetails: {
movie_data: {
stream_id: candidate.contentId,
container_extension: cachedContainer,
},
} as Parameters<XtreamUrlService['constructVodUrl']>[1],
metadata: { containerExtension: cachedContainer },
};
}
}
}
@@ -53,6 +53,8 @@ export class VodDetailsPlaybackService {
null
);
private lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches startTime. */
private resumeSettled = false;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
@@ -274,6 +276,7 @@ export class VodDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
this.resumeSettled = false;
}
handleInlineTimeUpdate(event: {
@@ -283,6 +286,19 @@ export class VodDetailsPlaybackService {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very position
// we are resuming from — which a source switch would then inherit.
//
// This is a one-shot latch, not a filter: once playback has reached
// the resume point the guard is done, so a deliberate seek backwards
// is still saved normally.
if (!this.resumeSettled) {
const startTime = playback.startTime ?? 0;
if (startTime > 0 && event.currentTime < startTime - 5) return;
this.resumeSettled = true;
}
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
@@ -324,8 +340,22 @@ export class VodDetailsPlaybackService {
});
}
/**
* The single inline-vs-external fork. Public so multi-source can switch
* the playing source through exactly the same path a normal Play takes —
* a second, parallel start path is how the two drift apart.
*
* For an inline switch this is a single `.set()` on a signal the host
* template already renders through `@if`, so the player component and its
* engine survive and simply re-seek to `playback.startTime`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
this.startPlayback(playback);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
this.resumeSettled = false;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
@@ -190,6 +190,19 @@
</span>
</button>
}
@if (multiSource.hasAlternatives()) {
<app-vod-sources-chip
[sources]="multiSource.sources()"
[title]="multiSourceTitle()"
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
(autoFailoverToggled)="setAutoFailover($event)"
/>
}
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
@@ -236,12 +249,44 @@
</button>
}
}
@if (activeSourceCaption(); as caption) {
<p class="source-caption">
<span class="source-caption__label">
{{ 'PORTALS.MULTI_SOURCE.PLAYING_FROM' | translate }}
</span>
<span class="source-caption__source">{{
caption.source
}}</span>
@if (multiSource.hasAlternatives()) {
<span class="source-caption__extra">
@if (caption.alternativeCount === 1) {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_ONE'
| translate
}}
} @else {
{{
'PORTALS.MULTI_SOURCE.ALSO_FOUND_IN_OTHER'
| translate
: { count: caption.alternativeCount }
}}
}
</span>
}
</p>
}
</ng-template>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
(sourceCheckRequested)="checkSource($event)"
(playbackFailed)="onPlaybackFailed()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
@@ -4,6 +4,27 @@
height: 100%;
min-height: 0;
// "Playing from: SharkTV · 1080p · mkv — also found in 2 playlists".
// Projected into the hero's action row, which is a flex row, so it claims
// a full-width line of its own below the buttons.
::ng-deep .source-caption {
flex: 0 0 100%;
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.4;
color: var(--text-muted);
.source-caption__source {
margin-left: 6px;
font-weight: 600;
color: var(--text-secondary);
}
.source-caption__extra {
margin-left: 6px;
}
}
::ng-deep .details__tag--status {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent-color) 28%, transparent);
@@ -20,6 +20,7 @@ import {
DetailMetaTemplateDirective,
DetailTagsTemplateDirective,
PortalDetailShellComponent,
VodSourcesChipComponent,
} from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import { createLogger } from '@iptvnator/portal/shared/util';
@@ -36,6 +37,7 @@ import {
} from '@iptvnator/services';
import {
getXtreamVodInfo,
playlistDisplayLabel,
normalizeTitleKeys,
TmdbEnrichedCastMember,
XtreamCategory,
@@ -53,6 +55,8 @@ import {
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -61,7 +65,7 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
'./vod-details-route.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService],
providers: [VodDetailsPlaybackService, VodMultiSourceHostService],
imports: [
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -72,6 +76,7 @@ import { VodDetailsPlaybackService } from './vod-details-playback.service';
SlicePipe,
TranslateModule,
PortalInlinePlayerComponent,
VodSourcesChipComponent,
],
})
export class VodDetailsRouteComponent implements OnInit, OnDestroy {
@@ -85,6 +90,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
/** Alternative sources for this movie in the user's other playlists */
readonly multiSource = inject(VodMultiSourceHostService);
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
@@ -172,6 +179,21 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
) ?? null
);
});
/** Movie identity for multi-source discovery; null until a title exists */
private readonly multiSourceMovie = computed(() =>
resolveVodMultiSourceMovie({
playlistId: this.xtreamStore.currentPlaylist()?.id,
// `title` is the alias the Xtream data source actually writes
// (createPlaylist maps name -> title), so reading only `name`
// would fall back to the raw playlist UUID in the sources list.
playlistName:
this.xtreamStore.currentPlaylist()?.name ??
this.xtreamStore.currentPlaylist()?.title,
vodId: this.selectedVodId(),
vodInfo: this.selectedVodInfo(),
catalogItem: this.selectedCatalogItem(),
})
);
readonly selectedVodInfo = computed(() => {
const item = this.selectedItem();
return item && hasUsableXtreamVodMetadata(item)
@@ -288,6 +310,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
});
this.multiSource.bind({
// Route every switch through the same inline-vs-external fork a
// normal Play uses, so the two paths cannot drift apart.
startPlayback: (playback) =>
this.playback.startResolvedPlayback(playback),
movie: this.multiSourceMovie,
});
// Initializes on first render and RE-initializes when the route
// params change while the component is reused (Similar rail).
@@ -418,10 +447,101 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.closeInlinePlayer();
}
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(
() => this.multiSourceMovie()?.title ?? ''
);
/** The ".srcbar" caption under the action row: where this is playing from. */
readonly activeSourceCaption = computed(() => {
const active = this.multiSource.sources().find((s) => s.isActive);
if (!active) {
return null;
}
// Only FACTS reach the caption. A guessed quality would read as a
// claim about the stream the user is watching right now.
const facts = [
active.quality?.provenance !== 'parsed'
? active.quality?.value
: null,
active.container?.provenance !== 'parsed'
? active.container?.value
: null,
].filter(Boolean);
return {
// Never the raw playlist name: it is routinely the pasted URL,
// credentials and all, and this line sits in the open on every
// screenshot of the detail page.
source: [
playlistDisplayLabel(active.playlistName, active.playlistId),
...facts,
].join(' · '),
alternativeCount: this.multiSource.alternativeCount(),
};
});
playFromSource(sourceId: string): void {
void this.multiSource.play(sourceId);
}
pinSource(sourceId: string): void {
void this.multiSource.togglePin(sourceId);
}
checkSource(sourceId: string): void {
void this.multiSource.check(sourceId);
}
setAutoFailover(enabled: boolean): void {
this.settingsStore.updateSettings({ vodAutoFailover: enabled });
}
/**
* A source failed. With auto-failover on we move to the best untried
* source and ANNOUNCE it; otherwise the player's own error overlay — which
* is already showing the alternatives — is left to do its job.
*/
async onPlaybackFailed(): Promise<void> {
const notice = await this.multiSource.failover();
if (!notice) {
return;
}
this.snackBar
.open(
this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCHED_TO',
{ playlist: notice.playlistName }
) +
(notice.audioMayDiffer
? ` — ${this.translateService.instant(
'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING'
)}`
: ''),
this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'),
{ duration: 10000 }
)
.onAction()
.subscribe(() => this.undoFailover());
}
/** Return to the source that was playing before the automatic switch. */
private undoFailover(): void {
const previousId = this.multiSource.previousSourceId();
if (previousId) {
void this.multiSource.play(previousId);
}
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
// Ahead of the service's 15s persistence throttle, so a source switch
// resumes from where playback actually is rather than up to 15s back.
this.multiSource.reportPosition(event.currentTime);
this.playback.handleInlineTimeUpdate(event);
}
@@ -0,0 +1,395 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
VodSourceDiscoveryService,
VodSourceResolverService,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type {
ResolvedPortalPlayback,
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
const MOVIE_A: VodMultiSourceMovie = {
playlistId: 'playlist-1',
playlistName: 'Playlist One',
contentId: 101,
title: 'The Matrix',
year: 1999,
};
const MOVIE_B: VodMultiSourceMovie = {
...MOVIE_A,
contentId: 202,
title: 'Blade Runner',
year: 1982,
}; // a second movie, to prove load() fully resets the session
const CURRENT_A_ID = 'playlist-1:xtream:101';
const API_AC3: VodSourceField = { value: 'ac3', provenance: 'api' };
const API_AAC: VodSourceField = { value: 'aac', provenance: 'api' };
const PARSED_DUB: VodSourceField = { value: 'Дубляж', provenance: 'parsed' };
const PROBE_OK = { status: 'ok', httpStatus: 200, latencyMs: 42 };
type DiscoveryResult = { sources: VodSourceCandidate[]; matchKind: string };
type AudioPicker = (c: VodSourceCandidate) => VodSourceField | undefined;
function alternative(index: number): VodSourceCandidate {
return {
id: `playlist-${index}:xtream:${900 + index}`,
playlistId: `playlist-${index}`,
playlistName: `Playlist ${index}`,
portalType: 'xtream',
contentId: 900 + index,
rawTitle: 'The Matrix',
matchConfidence: 'exact',
year: 1999,
};
}
const [ALT_TWO, ALT_THREE] = [alternative(2), alternative(3)];
function resolvedFor(
candidate: VodSourceCandidate,
startTime: number | undefined,
audio?: VodSourceField
) {
const playback: ResolvedPortalPlayback = {
streamUrl: `http://${candidate.playlistId}/${candidate.contentId}.mkv`,
title: candidate.rawTitle,
isLive: false,
startTime,
};
return { playback, candidate: { ...candidate, audio } };
}
/** Stands in for the resolver: echoes the start time, injects audio facts. */
function resolveWith(audioFor?: AudioPicker) {
return async (
candidate: VodSourceCandidate,
options?: { startTime?: number }
) => resolvedFor(candidate, options?.startTime, audioFor?.(candidate));
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('VodMultiSourceHostService', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
const resolver = { resolve: jest.fn() };
const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() };
const probes = { probe: jest.fn() };
/** Runs pending root effects, then drains the work they started. */
async function flushEffects(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
TestBed.tick();
await Promise.resolve();
}
}
async function loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
function resolvedIds(): string[] {
return resolver.resolve.mock.calls.map((call) => call[0].id);
}
/** Two consecutive switches, so the "previous" side carries real audio. */
async function switchTwice(
firstAudio: VodSourceField | undefined,
secondAudio: VodSourceField
) {
await loadMovie([ALT_TWO, ALT_THREE]);
resolver.resolve.mockImplementation(
resolveWith((candidate) =>
candidate.id === ALT_TWO.id ? firstAudio : secondAudio
)
);
await service.play(ALT_TWO.id);
await service.play(ALT_THREE.id);
return service.lastSwitch();
}
beforeEach(() => {
jest.resetAllMocks();
movie.set(null);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
sources: [],
matchKind: 'title-year',
});
resolver.resolve.mockImplementation(resolveWith());
pins.get.mockResolvedValue(null);
pins.set.mockResolvedValue(true);
pins.clear.mockResolvedValue(true);
probes.probe.mockResolvedValue(PROBE_OK);
TestBed.configureTestingModule({
providers: [
VodMultiSourceHostService,
{ provide: VodSourceDiscoveryService, useValue: discovery },
{ provide: VodSourceResolverService, useValue: resolver },
{ provide: VodSourcePinService, useValue: pins },
{ provide: StreamProbeService, useValue: probes },
{ provide: SettingsStore, useValue: { vodAutoFailover } },
],
});
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie })
);
});
it('discovers once per bound movie identity', async () => {
movie.set(MOVIE_A);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
expect(discovery.discover).toHaveBeenCalledWith({
title: 'The Matrix',
year: 1999,
currentPlaylistId: 'playlist-1',
});
movie.set({ ...MOVIE_A });
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(1);
movie.set(MOVIE_B);
await flushEffects();
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('counts only sources other than the playing one as alternatives', async () => {
await loadMovie([]);
expect(service.sources()).toHaveLength(1);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(service.hasAlternatives()).toBe(false);
expect(service.alternativeCount()).toBe(0);
await loadMovie([ALT_TWO]);
expect(service.sources()).toHaveLength(2);
expect(service.hasAlternatives()).toBe(true);
expect(service.alternativeCount()).toBe(1);
});
it('carries the live timecode into the resolve call and the playback', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 2538 }
);
expect(startPlayback).toHaveBeenCalledTimes(1);
expect(startPlayback).toHaveBeenCalledWith(
expect.objectContaining({ startTime: 2538 })
);
});
it('announces every switch with the target playlist', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
await service.play(ALT_TWO.id);
expect(service.lastSwitch()).toEqual(
expect.objectContaining({
playlistName: ALT_TWO.playlistName,
resumeSeconds: 2538,
})
);
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
});
it.each<[string, VodSourceField | undefined, VodSourceField, boolean]>([
['fires when both audio values are facts', API_AC3, API_AAC, true],
['is silent for a guessed audio', PARSED_DUB, API_AAC, false],
['is silent without both audio values', undefined, API_AAC, false],
])('the dub warning %s', async (_name, first, second, expected) => {
expect(await switchTwice(first, second)).toEqual(
expect.objectContaining({ audioMayDiffer: expected })
);
});
it('does not fail over while auto-failover is off', async () => {
await loadMovie([ALT_TWO]);
resolver.resolve.mockClear();
await expect(service.failover()).resolves.toBeNull();
expect(resolver.resolve).not.toHaveBeenCalled();
expect(startPlayback).not.toHaveBeenCalled();
});
it('fails over and announces it once auto-failover is enabled', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
const notice = await service.failover();
expect(notice).toEqual(
expect.objectContaining({ playlistName: ALT_TWO.playlistName })
);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(startPlayback).toHaveBeenCalledTimes(1);
});
it('never visits a source twice while failing over', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
const visited = resolvedIds();
expect(visited).toHaveLength(2);
expect(new Set(visited).size).toBe(visited.length);
expect(visited).not.toContain(CURRENT_A_ID);
expect(service.isExhausted()).toBe(true);
});
it('does not retry a source it could not resolve', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
vodAutoFailover.set(true);
resolver.resolve.mockResolvedValueOnce(null);
await expect(service.failover()).resolves.toBeNull();
const failedId = resolvedIds()[0];
expect(rowFor(failedId)?.isActive).toBe(false);
expect(rowFor(failedId)?.isTried).toBe(true);
expect(startPlayback).not.toHaveBeenCalled();
await expect(service.failover()).resolves.not.toBeNull();
expect(resolvedIds()[1]).not.toBe(failedId);
expect(rowFor(resolvedIds()[1])?.isActive).toBe(true);
});
it('round-trips a pin under the movie match key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(0);
const pin = {
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
};
await service.togglePin(ALT_TWO.id);
expect(pins.set).toHaveBeenCalledWith(pin);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
expect(pins.set).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
await loadMovie([ALT_TWO]);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false);
});
it('reports unknown, never fail, for a source it cannot check', async () => {
await loadMovie([ALT_TWO]);
resolver.resolve.mockResolvedValueOnce(null);
await service.check(ALT_TWO.id);
expect(rowFor(ALT_TWO.id)?.probe.status).toBe('unknown');
expect(probes.probe).not.toHaveBeenCalled();
await service.check(ALT_TWO.id);
expect(probes.probe).toHaveBeenCalledWith(
`http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`
);
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
});
it('discards a discovery that resolves after the movie changed', async () => {
const first = createDeferred<DiscoveryResult>();
const second = createDeferred<DiscoveryResult>();
discovery.discover
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const loadA = service.load(MOVIE_A);
const loadB = service.load(MOVIE_B);
first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadA;
expect(service.sources()).toEqual([]);
// Abandoned on arrival: it never even looks the stale pin up.
expect(pins.get).not.toHaveBeenCalled();
second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' });
await loadB;
expect(pins.get).toHaveBeenCalledTimes(1);
expect(service.sources().map((source) => source.id)).toEqual([
'playlist-1:xtream:202',
ALT_THREE.id,
]);
});
it('resets the failover session when a new movie loads', async () => {
await loadMovie([ALT_TWO]);
vodAutoFailover.set(true);
await expect(service.failover()).resolves.not.toBeNull();
await expect(service.failover()).resolves.toBeNull();
expect(service.previousSourceId()).toBe(CURRENT_A_ID);
await loadMovie([ALT_TWO], MOVIE_B);
expect(service.previousSourceId()).toBeNull();
expect(service.lastSwitch()).toBeNull();
await expect(service.failover()).resolves.not.toBeNull();
});
});
@@ -0,0 +1,350 @@
import {
computed,
effect,
inject,
Injectable,
signal,
type Signal,
} from '@angular/core';
import {
VodMultiSourceController,
VodSourceDiscoveryService,
VodSourceResolverService,
audioDiffersFactually,
} from '@iptvnator/portal/shared/data-access';
import {
SettingsStore,
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import {
vodMultiSourceMovieKey,
type VodMultiSourceMovie,
} from './vod-multi-source-identity';
import {
buildVodSourceMatchKey,
buildVodSourceMatchKeyCandidates,
type ResolvedPortalPlayback,
type VodSourceCandidate,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
/**
* Wires VOD multi-source into one open movie on the Xtream detail page.
*
* Component-provided, not root-provided: the controller's "each source is
* tried at most once" set must die with the movie, or a later film would
* inherit a poisoned failover history.
*
* The host component supplies the movie identity and the two playback seams
* (start a playback, read the live position) through `bind()`, so this service
* never reaches into the route component.
*/
export interface VodMultiSourceBindings {
/** Applies a playback — inline swap or external launch, host's choice. */
startPlayback: (playback: ResolvedPortalPlayback) => void;
/** The movie on screen, or null while its identity is not yet knowable. */
movie: Signal<VodMultiSourceMovie | null>;
}
export interface VodMultiSourceSwitchNotice {
playlistName: string;
resumeSeconds: number;
/** Both sides state an audio track AS FACT and those facts differ. */
audioMayDiffer: boolean;
quality?: string;
container?: string;
}
@Injectable()
export class VodMultiSourceHostService {
private readonly discovery = inject(VodSourceDiscoveryService);
private readonly resolver = inject(VodSourceResolverService);
private readonly pins = inject(VodSourcePinService);
private readonly probes = inject(StreamProbeService);
private readonly settingsStore = inject(SettingsStore);
private controller = new VodMultiSourceController();
private bindings: VodMultiSourceBindings | null = null;
private matchKeys: string[] = [];
/** Guards against a stale discovery resolving after the user moved on. */
private discoveryToken = 0;
private lastMovieKey: string | null = null;
/** The source that was playing before the most recent switch. */
private readonly _previousSourceId = signal<string | null>(null);
private readonly _sources = signal<VodSourceDescriptor[]>([]);
private readonly _busySourceId = signal<string | null>(null);
/** Emitted after a switch so the host can toast it — never silent. */
private readonly _lastSwitch = signal<VodMultiSourceSwitchNotice | null>(
null
);
readonly sources = this._sources.asReadonly();
readonly busySourceId = this._busySourceId.asReadonly();
readonly lastSwitch = this._lastSwitch.asReadonly();
readonly previousSourceId = this._previousSourceId.asReadonly();
readonly alternatives = computed(() =>
this._sources().filter((source) => !source.isActive)
);
/** The chip appears only when there is genuinely somewhere else to go. */
readonly hasAlternatives = computed(() => this.alternatives().length > 0);
readonly alternativeCount = computed(() => this.alternatives().length);
readonly matchKind = computed(() => this.controller.matchKind());
/** Opt-in and off by default — a silent switch is never acceptable. */
readonly autoFailoverEnabled = computed(
() => this.settingsStore.vodAutoFailover?.() === true
);
/** Live getter, not a snapshot: `isAvailable` is itself a getter over
* the Electron bridge, so copying it once could disagree with it. */
get isAvailable(): boolean {
return this.discovery.isAvailable;
}
/**
* Wire the host's playback seam and start watching the movie on screen.
*
* The effect lives here rather than in the route component because it is
* this service's own lifecycle: the router REUSES the detail component for
* detail-to-detail navigation, so a new movie must fully reset the session
* — above all the tried-source set that makes failover terminate.
*/
bind(bindings: VodMultiSourceBindings): void {
this.bindings = bindings;
effect(() => {
const movie = bindings.movie();
if (!movie) {
return;
}
const key = vodMultiSourceMovieKey(movie);
if (this.lastMovieKey === key) {
return;
}
this.lastMovieKey = key;
void this.load(movie);
});
}
/**
* Start a fresh session for a movie. The router REUSES this component for
* detail→detail navigation, so this must fully reset — including the
* tried-source set.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const token = ++this.discoveryToken;
this.controller = new VodMultiSourceController();
this._sources.set([]);
this._lastSwitch.set(null);
this._busySourceId.set(null);
this._previousSourceId.set(null);
this.matchKeys = buildVodSourceMatchKeyCandidates(movie);
if (!this.discovery.isAvailable) {
return;
}
const result = await this.discovery.discover({
title: movie.title,
year: movie.year,
currentPlaylistId: movie.playlistId,
});
if (token !== this.discoveryToken) {
return;
}
// The playing source is part of the list, so the popover can show it
// as current rather than pretending the movie came from nowhere.
const current: VodSourceCandidate = {
id: `${movie.playlistId}:xtream:${movie.contentId}`,
playlistId: movie.playlistId,
playlistName: movie.playlistName,
portalType: 'xtream',
contentId: movie.contentId,
rawTitle: movie.title,
matchConfidence: 'exact',
year: movie.year ?? null,
};
this.controller.setSources(
[current, ...result.sources],
result.matchKind
);
this.controller.setActiveSource(current.id);
const pin = await this.pins.get(this.matchKeys);
if (token !== this.discoveryToken) {
return;
}
if (pin) {
this.controller.setPinnedSource(
`${pin.playlistId}:${pin.portalType}:${pin.contentId}`
);
}
this.publish();
}
/** Play from a specific source once; does not change the pin. */
async play(sourceId: string): Promise<boolean> {
const candidate = this.controller.findSource(sourceId);
if (!candidate || !this.bindings) {
return false;
}
this._busySourceId.set(sourceId);
try {
return await this.switchTo(candidate);
} finally {
this._busySourceId.set(null);
}
}
/** Pin or unpin this source as the movie's preferred one. */
async togglePin(sourceId: string): Promise<void> {
if (this.matchKeys.length === 0) {
return;
}
const alreadyPinned = this._sources().some(
(source) => source.id === sourceId && source.isPinned
);
if (alreadyPinned) {
await this.pins.clear(this.matchKeys);
this.controller.setPinnedSource(null);
this.publish();
return;
}
const candidate = this.controller.findSource(sourceId);
if (!candidate) {
return;
}
// Written under the most-trusted key; lookups pass every alias, so a
// TMDB id arriving later does not orphan this row.
const matchKey = this.matchKeys[0] ?? buildVodSourceMatchKey(candidate);
if (!matchKey) {
return;
}
await this.pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
});
this.controller.setPinnedSource(sourceId);
this.publish();
}
/** User-triggered availability check for one row. */
async check(sourceId: string): Promise<void> {
const candidate = this.controller.findSource(sourceId);
if (!candidate) {
return;
}
this.controller.setProbe(sourceId, { status: 'probing' });
this.publish();
const resolved = await this.resolver.resolve(candidate);
if (!resolved) {
// We could not even build a URL — that is "cannot check", not
// "this source is dead".
this.controller.setProbe(sourceId, { status: 'unknown' });
this.publish();
return;
}
this.controller.updateSource(resolved.candidate);
const result = await this.probes.probe(resolved.playback.streamUrl);
this.controller.setProbe(sourceId, result);
this.publish();
}
/**
* Automatically move to the best untried source after a playback failure.
*
* Returns the notice to announce, or null when auto-failover is off or
* every source has been tried — in which case the caller shows the honest
* error screen instead.
*/
async failover(): Promise<VodMultiSourceSwitchNotice | null> {
if (!this.autoFailoverEnabled()) {
return null;
}
const target = this.controller.pickFailoverTarget();
if (!target) {
return null;
}
const switched = await this.switchTo(target);
return switched ? this._lastSwitch() : null;
}
/** True once every alternative has been attempted this session. */
isExhausted(): boolean {
return this.controller.isExhausted();
}
/** Feeds the live player position; called ahead of the persist throttle. */
reportPosition(seconds: number): void {
this.controller.setResumeSeconds(seconds);
}
private async switchTo(candidate: VodSourceCandidate): Promise<boolean> {
if (!this.bindings) {
return false;
}
// Read the LIVE position, not the persisted one: the DB value lags by
// up to 15 seconds and switching would visibly rewind.
const resumeSeconds = Math.floor(this.controller.getResumeSeconds());
const previous = this.controller.findSource(
this.controller.activeSourceId() ?? ''
);
const resolved = await this.resolver.resolve(candidate, {
startTime: resumeSeconds,
});
if (!resolved) {
// Mark it tried without making it active: a source we cannot even
// build a URL for must not be offered again by failover, but it
// never started playing either.
this.controller.markTried(candidate.id);
this.publish();
return false;
}
this.controller.updateSource(resolved.candidate);
this._previousSourceId.set(previous?.id ?? null);
this.controller.setActiveSource(candidate.id);
this.controller.setResumeSeconds(resumeSeconds);
this.bindings.startPlayback(resolved.playback);
this._lastSwitch.set({
playlistName: candidate.playlistName,
resumeSeconds,
audioMayDiffer: audioDiffersFactually(previous, resolved.candidate),
quality: resolved.candidate.quality?.value,
container: resolved.candidate.container?.value,
});
this.publish();
return true;
}
private publish(): void {
this._sources.set(this.controller.sources());
}
}
@@ -0,0 +1,74 @@
import {
extractYear,
type XtreamVodInfo,
type XtreamVodStream,
} from '@iptvnator/shared/interfaces';
/**
* Who the movie on screen is, in the portal-agnostic terms multi-source needs.
*
* Pulled out of the host service so the "which of these three shapes carries
* the title" question stays a pure, testable function.
*/
export interface VodMultiSourceMovie {
playlistId: string;
playlistName: string;
contentId: number;
title: string;
year: number | null;
tmdbId?: number | string | null;
}
type CatalogItem =
| (Partial<XtreamVodStream> & { title?: string })
| null
| undefined;
/**
* Resolve the movie identity, or null while it is not yet knowable.
*
* The title can arrive from either the catalog row (present immediately) or
* `get_vod_info` (arrives later, and again after TMDB enrichment replaces
* `selectedItem`). Returning null until one exists keeps discovery from
* firing on an empty query.
*/
export function resolveVodMultiSourceMovie(input: {
playlistId: string | null | undefined;
playlistName: string | null | undefined;
vodId: number;
vodInfo: XtreamVodInfo | null;
catalogItem: CatalogItem;
}): VodMultiSourceMovie | null {
const { playlistId, vodId, vodInfo, catalogItem } = input;
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) {
return null;
}
const title =
vodInfo?.name?.trim() ||
catalogItem?.title?.trim() ||
catalogItem?.name?.trim();
if (!title) {
return null;
}
return {
playlistId,
playlistName: input.playlistName?.trim() || playlistId,
contentId: vodId,
title,
year: extractYear(vodInfo?.releasedate, title),
tmdbId: vodInfo?.tmdb_id,
};
}
/**
* Identity key for "this is still the same movie".
*
* Includes the title because it can arrive after the ids do — the first pass
* may run against the catalog title and a better one may follow.
*/
export function vodMultiSourceMovieKey(movie: VodMultiSourceMovie): string {
return `${movie.playlistId}:${movie.contentId}:${movie.title}`;
}
+2
View File
@@ -16,3 +16,5 @@ export * from './lib/settings-store.service';
export * from './lib/sort.service';
export * from './lib/tmdb';
export * from './lib/xtream-pending-restore.service';
export * from './lib/stream-probe.service';
export * from './lib/vod-source-pin.service';
@@ -29,6 +29,7 @@ const DEFAULT_SETTINGS: Settings = {
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
vodAutoFailover: false,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -196,6 +197,9 @@ export const SettingsStore = signalStore(
playerUpNextRail:
store.playerUpNextRail?.() ??
DEFAULT_SETTINGS.playerUpNextRail,
vodAutoFailover:
store.vodAutoFailover?.() ??
DEFAULT_SETTINGS.vodAutoFailover,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
@@ -0,0 +1,121 @@
import { Injectable } from '@angular/core';
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
/**
* On-demand stream reachability checks via the Electron main process.
*
* The renderer cannot do this itself: a browser request to an arbitrary IPTV
* host is CORS-blocked, and `no-cors` returns an opaque response in which 200,
* 403 and 404 are indistinguishable. So in the PWA `isAvailable` is false and
* every source stays honestly "unchecked" rather than being guessed at.
*
* Probing is explicitly user-triggered — this is not a background pinger.
*/
/** Results older than this are re-fetched; a stream can die at any moment. */
const CACHE_TTL_MS = 30_000;
interface CacheEntry {
result: VodSourceProbeResult;
storedAt: number;
}
@Injectable({ providedIn: 'root' })
export class StreamProbeService {
private readonly cache = new Map<string, CacheEntry>();
/** Deduplicates concurrent probes of the same URL into one request. */
private readonly inFlight = new Map<string, Promise<VodSourceProbeResult>>();
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.probeStreamUrl === 'function'
);
}
/** A cached result for this URL, if one is still fresh. */
peek(url: string): VodSourceProbeResult | null {
const entry = this.cache.get(url);
if (!entry || Date.now() - entry.storedAt > CACHE_TTL_MS) {
return null;
}
return entry.result;
}
async probe(
url: string,
method: 'GET' | 'HEAD' = 'HEAD'
): Promise<VodSourceProbeResult> {
if (!this.isAvailable || !url) {
// Not "offline" — we are simply unable to find out.
return { status: 'unknown' };
}
const cached = this.peek(url);
if (cached) {
return cached;
}
const pending = this.inFlight.get(url);
if (pending) {
return pending;
}
const request = this.runProbe(url, method).finally(() => {
this.inFlight.delete(url);
});
this.inFlight.set(url, request);
return request;
}
/** Drops cached results, e.g. after a playlist refresh changed the URLs. */
invalidate(): void {
this.cache.clear();
}
private async runProbe(
url: string,
method: 'GET' | 'HEAD'
): Promise<VodSourceProbeResult> {
let result: VodSourceProbeResult;
try {
const response = await window.electron.probeStreamUrl(url, method);
result = {
status: toProbeStatus(response.status),
httpStatus: response.status,
latencyMs: response.latencyMs,
probedAt: new Date().toISOString(),
};
} catch (error) {
console.warn('Stream probe failed:', error);
result = { status: 'unknown' };
}
// Only cache answers. An 'unknown' is a failure to measure, and
// caching it would keep a perfectly good source looking unverified
// for the whole TTL.
if (result.status !== 'unknown') {
this.cache.set(url, { result, storedAt: Date.now() });
}
return result;
}
}
/**
* Map an HTTP status onto a verdict.
*
* Status 0 means no response was obtained at all — a timeout, or a URL the
* redirect-safety policy refused. That is NOT evidence the source is dead, so
* it maps to 'unknown' and the UI keeps offering a check.
*/
function toProbeStatus(httpStatus: number): VodSourceProbeResult['status'] {
if (httpStatus === 0) {
return 'unknown';
}
if (httpStatus >= 200 && httpStatus < 400) {
return 'ok';
}
return 'fail';
}
@@ -0,0 +1,69 @@
import { Injectable } from '@angular/core';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
/**
* Persistence for the per-movie pinned source ("play this film from here").
*
* Electron-only, like the discovery that feeds it: without cross-playlist
* matching there is nothing to pin between. In the PWA every call is a no-op
* and `isAvailable` is false, so the UI hides the affordance rather than
* offering a button that silently forgets.
*/
@Injectable({ providedIn: 'root' })
export class VodSourcePinService {
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbGetVodSourcePin === 'function'
);
}
/**
* @param matchKeys every key this movie may be stored under, MOST TRUSTED
* FIRST. A film pinned before TMDB enrichment landed is keyed by title and
* prefers a `tmdb:` key afterwards; passing both means the id arriving
* later does not orphan the pin.
*/
async get(matchKeys: string[]): Promise<VodSourcePin | null> {
if (!this.isAvailable || matchKeys.length === 0) {
return null;
}
try {
return await window.electron.dbGetVodSourcePin(matchKeys);
} catch (error) {
console.warn('Reading the pinned VOD source failed:', error);
return null;
}
}
async set(pin: VodSourcePin): Promise<boolean> {
if (!this.isAvailable) {
return false;
}
try {
const result = await window.electron.dbSetVodSourcePin(pin);
return result?.success === true;
} catch (error) {
console.warn('Pinning the VOD source failed:', error);
return false;
}
}
/** Clears the pin under every alias, so unpinning cannot be undone by a
* stale row stored under the other key form. */
async clear(matchKeys: string[]): Promise<boolean> {
if (!this.isAvailable || matchKeys.length === 0) {
return false;
}
try {
const result = await window.electron.dbClearVodSourcePin(matchKeys);
return result?.success === true;
} catch (error) {
console.warn('Unpinning the VOD source failed:', error);
return false;
}
}
}
@@ -301,6 +301,18 @@ const CREATE_TABLE_STATEMENTS = [
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`,
// VOD multi-source pins — the per-movie preferred playlist. Keyed by a
// portal-agnostic match key, since the same film has a different provider
// id in every portal.
`CREATE TABLE IF NOT EXISTS vod_source_pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_key TEXT NOT NULL UNIQUE,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
content_id INTEGER NOT NULL,
portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id)`,
// Playback Positions table
`CREATE TABLE IF NOT EXISTS playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+1
View File
@@ -19,6 +19,7 @@ import {
// Manual EPG-to-channel mappings live in their own schema module; re-export
// them so `import * as schema from './schema'` keeps a complete namespace.
export * from './epg-mapping.schema';
export * from './vod-source-pins.schema';
// Playlists table
export const playlists = sqliteTable('playlists', {
@@ -0,0 +1,41 @@
/**
* Drizzle ORM schema for VOD multi-source pins — the user's per-movie choice
* of which playlist a film should play from.
*
* Split out of schema.ts to keep that file within the repository's
* max-lines budget; re-exported from there so `import * as schema`
* consumers keep seeing a single schema namespace.
*/
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
// Per-movie preferred source. Keyed by a portal-agnostic match key
// (`tmdb:{id}` or `title:{base}:{year}`) rather than by a provider id,
// because the same film has a different id in every portal.
//
// The playlist foreign key with ON DELETE CASCADE lives in the raw
// CREATE TABLE in connection.ts — which is what actually creates this table —
// rather than in a Drizzle `.references()`. Declaring it here would mean
// importing `playlists` from schema.ts, which re-exports this module, and that
// import cycle is exactly what `epg-mapping.schema.ts` avoids too.
export const vodSourcePins = sqliteTable(
'vod_source_pins',
{
id: integer('id').primaryKey({ autoIncrement: true }),
matchKey: text('match_key').notNull().unique(),
playlistId: text('playlist_id').notNull(),
// Provider-side id within that playlist (Xtream `stream_id`).
contentId: integer('content_id').notNull(),
portalType: text('portal_type', {
enum: ['xtream', 'stalker', 'm3u'],
}).notNull(),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
playlistIdx: index('idx_vod_source_pins_playlist').on(table.playlistId),
})
);
export type VodSourcePinRow = typeof vodSourcePins.$inferSelect;
export type NewVodSourcePinRow = typeof vodSourcePins.$inferInsert;
+3
View File
@@ -30,6 +30,7 @@ export * from './lib/playlist-refresh.interface';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/playlist-display-label.util';
export * from './lib/portal-playback.interface';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
@@ -42,6 +43,8 @@ export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/vod-source.interface';
export * from './lib/vod-source-match-key.util';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
export * from './lib/xtream-code-actions';
@@ -32,6 +32,10 @@ import {
} from './portal-playback.interface';
import { PortalDebugEvent } from './portal-debug.interface';
import { CatalogTitleMatch } from './catalog-title-match.interface';
import {
VodSourceCandidateRow,
VodSourcePin,
} from './vod-source.interface';
import { Settings } from './settings.interface';
import {
TmdbCacheEntry,
@@ -254,8 +258,17 @@ export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult {
}
export interface ElectronBridgeXtreamProbeResult {
/**
* HTTP status, or 0 when no response was obtained.
*
* 0 covers a timeout and a URL rejected by the redirect-safety policy as
* well as a dead host, so it means "could not check" — never "offline".
*/
status: number;
url: string;
/** Round-trip time; present whenever the request completed either way. */
latencyMs?: number;
error?: string;
}
export interface ElectronBridgeEpgFetchResult extends ElectronBridgeResult {
@@ -672,6 +685,11 @@ export interface ElectronBridgeApi {
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
/** Generic stream reachability probe (VOD multi-source availability) */
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD'
) => Promise<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
) => Promise<Playlist | PlaylistRefreshCancelledResult>;
@@ -849,6 +867,18 @@ export interface ElectronBridgeApi {
>;
/** Cross-playlist title matching (actor page "All portals" scope) */
dbMatchTitles: (titles: string[]) => Promise<CatalogTitleMatch[]>;
/** VOD multi-source: the same movie in the user's other Xtream playlists */
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => Promise<VodSourceCandidateRow[]>;
/** Per-movie pinned source; keys are passed most-trusted first */
dbGetVodSourcePin: (matchKeys: string[]) => Promise<VodSourcePin | null>;
dbSetVodSourcePin: (pin: VodSourcePin) => Promise<ElectronBridgeResult>;
dbClearVodSourcePin: (
matchKeys: string[]
) => Promise<ElectronBridgeResult>;
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => () => void;
@@ -0,0 +1,84 @@
import {
playlistDisplayLabel,
playlistNameHasCredentials,
} from './playlist-display-label.util';
describe('playlistDisplayLabel', () => {
it('collapses a credential-bearing URL to its host', () => {
expect(
playlistDisplayLabel(
'http://vipmax.site:8080/get.php?username=00%3A1A%3A79&password=5fd87ba4&type=m3u_plus'
)
).toBe('vipmax.site:8080');
});
it('strips credentials typed outside the URL', () => {
// The default :80 is dropped by URL normalization, which reads better
// anyway; a non-default port is kept (see the case above).
expect(
playlistDisplayLabel('http://marveltv.info:80 usr== Tl24uy0xGi pas=== pNt9PQMmL2')
).toBe('marveltv.info');
});
it('keeps a name the user actually chose', () => {
expect(playlistDisplayLabel('DE movies/kinder')).toBe(
'DE movies/kinder'
);
expect(playlistDisplayLabel('cord-cutter')).toBe('cord-cutter');
});
it('removes hand-typed credentials from a non-URL name', () => {
expect(
playlistDisplayLabel('MyPortal Username: abc Password: s3cret')
).toBe('MyPortal');
});
it('never renders a password fragment', () => {
const labels = [
'http://x.tv:80/get.php?username=joe&password=hunter2',
'Portal user==joe pass==hunter2',
'http://62.182.83.108 USERNAME : 99558627 PASSWORD : uUhQQlcHjD',
].map((n) => playlistDisplayLabel(n));
for (const label of labels) {
expect(label).not.toMatch(/hunter2|uUhQQlcHjD|99558627/);
}
});
it('truncates an over-long name', () => {
const label = playlistDisplayLabel('a'.repeat(120));
expect(label.length).toBeLessThanOrEqual(42);
expect(label.endsWith('…')).toBe(true);
});
it('falls back when there is nothing to show', () => {
expect(playlistDisplayLabel('', 'playlist-1')).toBe('playlist-1');
expect(playlistDisplayLabel(null, 'playlist-1')).toBe('playlist-1');
expect(playlistDisplayLabel(undefined)).toBe('');
});
it('does not return an empty label for a credentials-only name', () => {
expect(playlistDisplayLabel('username=joe password=x')).toBe(
'Playlist'
);
});
});
describe('playlistNameHasCredentials', () => {
it('detects query-string credentials', () => {
expect(
playlistNameHasCredentials('http://x.tv/get.php?username=a&password=b')
).toBe(true);
});
it('detects hand-typed credentials', () => {
expect(playlistNameHasCredentials('Portal usr== abc pas== def')).toBe(
true
);
});
it('is false for an ordinary name', () => {
expect(playlistNameHasCredentials('cord-cutter')).toBe(false);
expect(playlistNameHasCredentials('http://plain.tv:8080')).toBe(false);
});
});
@@ -0,0 +1,82 @@
/**
* A short, safe label for a playlist.
*
* Users routinely name a playlist after the URL they pasted, and those URLs
* carry `username=` / `password=` query parameters — or even
* `usr== X pas== Y` typed into the name by hand. Rendering that verbatim puts
* live credentials on screen in a list that exists to be looked at, screenshotted
* and screen-shared.
*
* So anything URL-shaped collapses to its host, and any credential-looking
* fragment is dropped. A name the user actually chose is left alone.
*/
/** Query keys whose presence means the string is a credential-bearing URL. */
const CREDENTIAL_KEYS = /(?:^|[?&;])(?:username|password|user|pass|token|auth)=/i;
/** Hand-typed credential fragments, e.g. "usr== Tl24uy0xGi pas== abc". */
const INLINE_CREDENTIALS =
/\b(?:usr|user|username|pas|pass|password|login|token)\s*[:=]+\s*\S+/gi;
const MAX_LABEL_LENGTH = 42;
/**
* Turn a stored playlist name into something short enough to scan and safe
* enough to show. Never returns an empty string when given any input, so a row
* can always be identified by something.
*/
export function playlistDisplayLabel(
rawName: string | null | undefined,
fallback = ''
): string {
const name = (rawName ?? '').trim();
if (!name) {
return fallback;
}
const host = extractHost(name);
if (host) {
return host;
}
// Not a URL, but may still have credentials typed into the name.
const cleaned = name
.replace(INLINE_CREDENTIALS, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
const safe = cleaned || fallback || 'Playlist';
return safe.length > MAX_LABEL_LENGTH
? `${safe.slice(0, MAX_LABEL_LENGTH - 1).replace(/\s+$/, '')}…`
: safe;
}
/** True when the stored name would have leaked credentials as-is. */
export function playlistNameHasCredentials(
rawName: string | null | undefined
): boolean {
const name = rawName ?? '';
return CREDENTIAL_KEYS.test(name) || INLINE_CREDENTIALS.test(name);
}
/**
* The host of the first URL in the string, port included.
*
* Handles both a clean URL and the common "http://host:8080 user== x pas== y"
* shape, where the credentials sit outside the URL entirely.
*/
function extractHost(value: string): string | null {
const match = value.match(/https?:\/\/[^\s/?#]+/i);
if (!match) {
return null;
}
try {
const url = new URL(match[0]);
return url.host || null;
} catch {
// Malformed but clearly URL-shaped — strip the scheme by hand rather
// than fall through and render the credential-bearing remainder.
return match[0].replace(/^https?:\/\//i, '') || null;
}
}
@@ -96,6 +96,16 @@ export interface Settings {
* missing values mean enabled. Only affects the built-in web players.
*/
playerUpNextRail?: boolean;
/**
* When a movie fails to play and the same film exists in another imported
* playlist, switch to it automatically instead of showing the error.
*
* Off by default and deliberately opt-in: another source can carry a
* different dub or cut, so switching is never silent — the toast always
* announces it and offers an undo. Each source is tried at most once per
* session, so this cannot loop.
*/
vodAutoFailover?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
@@ -0,0 +1,89 @@
/**
* Portal-agnostic identity for a movie, used to key multi-source pins.
*
* Provider ids cannot be used: the same film carries a different `stream_id`
* in every portal, which is precisely the problem this feature solves. A TMDB
* id is the strongest identity when both sides have one, but Stalker rarely
* does and Xtream's arrives asynchronously after enrichment — so the title
* form has to work standalone and stay stable when a TMDB id shows up later.
*/
import { normalizeTitleKeys } from './title-normalization.util';
const TMDB_PREFIX = 'tmdb:';
const TITLE_PREFIX = 'title:';
/**
* Build the canonical key for a movie.
*
* Prefers `tmdb:{id}`; falls back to `title:{normalizedBase}:{year}`. The
* year-stripped `base` form is used deliberately: the trailing year in a
* provider title is usually a release tag, and portals disagree about whether
* to include it.
*
* Returns `null` when there is nothing identifying to key on, so callers are
* forced to handle "cannot pin this" rather than silently writing a junk row.
*/
export function buildVodSourceMatchKey(input: {
tmdbId?: number | string | null;
title?: string | null;
year?: number | null;
}): string | null {
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
return `${TMDB_PREFIX}${tmdbId}`;
}
const { base } = normalizeTitleKeys(input.title);
if (!base) {
return null;
}
return `${TITLE_PREFIX}${base}:${input.year ?? ''}`;
}
/**
* Every key a movie may legitimately be stored under, most-trusted first.
*
* A pin written before TMDB enrichment lands is keyed by title; after
* enrichment the same movie prefers a `tmdb:` key. Looking up both means the
* earlier pin is not orphaned by the id arriving.
*/
export function buildVodSourceMatchKeyCandidates(input: {
tmdbId?: number | string | null;
title?: string | null;
year?: number | null;
}): string[] {
const keys: string[] = [];
const tmdbId = normalizeTmdbId(input.tmdbId);
if (tmdbId !== null) {
keys.push(`${TMDB_PREFIX}${tmdbId}`);
}
const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null });
if (titleKey && !keys.includes(titleKey)) {
keys.push(titleKey);
}
return keys;
}
export function isTmdbMatchKey(key: string): boolean {
return key.startsWith(TMDB_PREFIX);
}
/**
* Coerce a provider-supplied TMDB id to a positive integer.
*
* Xtream sends this field as a string, as `0`, and as `""` depending on the
* panel build; none of those are usable ids.
*/
function normalizeTmdbId(raw: number | string | null | undefined): number | null {
if (raw === null || raw === undefined || raw === '') {
return null;
}
const parsed = Number(raw);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
@@ -0,0 +1,136 @@
/**
* VOD multi-source: the same movie found across several imported playlists.
*
* The central rule of this feature is that the UI must never present a guess
* as a fact. Every metadata field therefore carries WHERE its value came from,
* and consumers branch on that provenance instead of on the value alone.
*/
/**
* Where a metadata value came from, in descending order of trust.
*
* - `api` the provider stated it (Xtream `get_vod_info`) — a fact
* - `parsed` inferred from the title/filename by regex — a guess
* - `probe` observed by contacting the stream — a fact
*
* v1 only ever produces `probe` for reachability and latency: reading real
* codecs would need ffprobe, which this app does not ship.
*/
export type VodSourceProvenance = 'api' | 'parsed' | 'probe';
/**
* A metadata value tagged with its origin. Absent (`undefined`) means "we do
* not know" — which the UI renders as no tag at all, never as a blank or a
* placeholder value.
*/
export interface VodSourceField<T = string> {
value: T;
provenance: VodSourceProvenance;
}
/** How confidently this source was matched to the movie being viewed. */
export type VodSourceMatchConfidence = 'exact' | 'fuzzy';
/** How the whole source list was matched — drives the popover header text. */
export type VodSourceMatchKind = 'tmdb' | 'title-year';
export type VodSourcePortalType = 'xtream' | 'stalker' | 'm3u';
/** Outcome of an on-demand availability probe. */
export type VodSourceProbeStatus =
| 'idle'
| 'probing'
/** Reachable: HEAD (or the Range-GET fallback) returned a success status */
| 'ok'
/** Contacted and refused: a definite non-success HTTP status */
| 'fail'
/**
* Could not be checked — request blocked by policy, timed out, or the
* runtime has no probe capability. Deliberately distinct from `fail`: an
* unchecked source must never be displayed as offline.
*/
| 'unknown';
export interface VodSourceProbeResult {
status: VodSourceProbeStatus;
/** HTTP status when one was actually received; 0 when none was. */
httpStatus?: number;
/** Round-trip time in milliseconds, when the request completed. */
latencyMs?: number;
/** ISO timestamp of the check, for the "checked yesterday" tag. */
probedAt?: string;
}
/**
* A raw discovery hit, exactly as the database returns it.
*
* Deliberately narrow: it is what the `content` table can prove, and nothing
* more. Notably it carries no container, codec or audio, because those columns
* do not exist — learning them costs a live request to the foreign portal.
*/
export interface VodSourceCandidateRow {
playlistId: string;
playlistName: string;
categoryId: number;
xtreamId: number;
title: string;
posterUrl: string | null;
matchConfidence: VodSourceMatchConfidence;
year: number | null;
}
/**
* A candidate source as it comes out of discovery: enough to render a row and
* to resolve a stream URL later, but NOT yet playable. Resolving the URL costs
* a live request against the foreign playlist, so it is deferred until the
* user actually asks for this source.
*/
export interface VodSourceCandidate {
/** Stable within a session: `${playlistId}:${portalType}:${contentId}` */
id: string;
playlistId: string;
playlistName: string;
portalType: VodSourcePortalType;
/** Provider-side id of the item (Xtream `stream_id`). */
contentId: number;
/** The provider's own title, unnormalized — shown for fuzzy matches. */
rawTitle: string;
matchConfidence: VodSourceMatchConfidence;
/** Release year when known, for disambiguating fuzzy matches. */
year?: number | null;
posterUrl?: string | null;
quality?: VodSourceField;
codec?: VodSourceField;
container?: VodSourceField;
audio?: VodSourceField;
/** ISO timestamp of the last failed playback attempt, if any. */
lastFailedAt?: string;
}
/**
* A candidate plus the live session state the UI needs to render it.
*/
export interface VodSourceDescriptor extends VodSourceCandidate {
isActive: boolean;
isPinned: boolean;
/** Already attempted this session — failover must not retry it. */
isTried: boolean;
probe: VodSourceProbeResult;
}
/**
* A user's per-movie choice of preferred source.
*
* Keyed by `matchKey` (see `vod-source-match-key.util.ts`) rather than by a
* provider id, because the whole point is that this movie exists under
* different ids in different portals.
*/
export interface VodSourcePin {
matchKey: string;
playlistId: string;
contentId: number;
portalType: VodSourcePortalType;
updatedAt?: string;
}
+4
View File
@@ -16,5 +16,9 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
export * from './lib/vod-sources/vod-source-row.component';
export * from './lib/vod-sources/vod-source-tags';
export * from './lib/vod-sources/vod-sources-chip.component';
export * from './lib/vod-sources/vod-sources-menu.component';
export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component';
@@ -446,7 +446,11 @@
}
::ng-deep .favorite-btn,
::ng-deep .download-btn {
::ng-deep .download-btn,
// The sources chip is a peer secondary action, so it wears the same
// surface, height and radius. Without this it reads as a link floating
// between three solid buttons.
::ng-deep app-vod-sources-chip .sources-chip {
@include detail-actions.secondary-action-button;
}
@@ -0,0 +1,75 @@
import {
playlistDisplayLabel,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
/**
* Collapses sources that come from the same playlist.
*
* One playlist frequently carries the same film several times under different
* stream ids — different rips, dubs or quality ladders. Flat, those rows are
* indistinguishable: same label, same monogram, same tags. Grouping turns "five
* identical rows" into "one playlist, 5 copies" and moves the difference into
* the expanded variants, where it can actually be read.
*/
export interface VodSourceGroup {
/** Stable across renders: the playlist is the group. */
key: string;
label: string;
/** The row shown when collapsed. */
primary: VodSourceDescriptor;
/** Every source in the group, primary included, in discovery order. */
sources: VodSourceDescriptor[];
/** Collapsed groups still need to advertise how much they hide. */
count: number;
hasActive: boolean;
}
export function groupVodSources(
sources: readonly VodSourceDescriptor[]
): VodSourceGroup[] {
const byPlaylist = new Map<string, VodSourceDescriptor[]>();
for (const source of sources) {
const existing = byPlaylist.get(source.playlistId);
if (existing) {
existing.push(source);
} else {
byPlaylist.set(source.playlistId, [source]);
}
}
return [...byPlaylist.entries()].map(([key, groupSources]) => ({
key,
label: playlistDisplayLabel(
groupSources[0].playlistName,
groupSources[0].playlistId
),
// The playing copy must be the face of the group, or the user sees
// "5 copies" with no indication that one of them is on screen.
primary: groupSources.find((s) => s.isActive) ?? groupSources[0],
sources: groupSources,
count: groupSources.length,
hasActive: groupSources.some((s) => s.isActive),
}));
}
/**
* What tells two copies from the same playlist apart.
*
* Their tags are usually identical (both parsed from near-identical titles), so
* the provider's own raw title is the only honest discriminator left. Falls
* back to the stream id when even the titles match.
*/
export function variantLabel(
source: VodSourceDescriptor,
siblings: readonly VodSourceDescriptor[]
): string {
const titles = new Set(siblings.map((s) => s.rawTitle));
if (titles.size > 1 && source.rawTitle) {
return source.rawTitle;
}
return `#${source.contentId}`;
}
@@ -0,0 +1,94 @@
<div class="source-row" [class.source-row--active]="source().isActive">
<span class="source-row__monogram" aria-hidden="true">{{
monogram()
}}</span>
<div class="source-row__info">
<div class="source-row__name-line">
<span class="source-row__name" [title]="displayLabel()">{{
displayLabel()
}}</span>
@if (source().isActive) {
<span class="source-row__badge">{{
'PORTALS.MULTI_SOURCE.PLAYING' | translate
}}</span>
}
</div>
<div class="source-row__tags">
@for (tag of tags(); track tag.id) {
<span
class="source-tag"
[class.source-tag--ok]="tag.tone === 'ok'"
[class.source-tag--warn]="tag.tone === 'warn'"
[class.source-tag--bad]="tag.tone === 'bad'"
>
@if (tag.spinner) {
<span
class="source-tag__spinner"
aria-hidden="true"
></span>
} @else if (tag.icon) {
<mat-icon class="source-tag__icon">{{
tag.icon
}}</mat-icon>
}
@if (tag.prefix) {
<span class="source-tag__prefix">{{ tag.prefix }}</span>
}
@if (tag.labelKey) {
<span>{{ tag.labelKey | translate }}</span>
} @else {
<span>{{ tag.text }}</span>
}
@if (tag.suffix) {
<span class="source-tag__suffix">{{ tag.suffix }}</span>
}
</span>
}
@if (showCheckChip()) {
<button
type="button"
class="source-tag source-tag--action"
(click)="onCheck($event)"
[matTooltip]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.CHECK_TOOLTIP' | translate
"
>
<mat-icon class="source-tag__icon">refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK' | translate }}</span>
</button>
}
</div>
</div>
<div class="source-row__actions">
@if (showPin()) {
<button
type="button"
class="source-row__action"
[class.is-active]="source().isPinned"
[attr.aria-pressed]="source().isPinned"
(click)="onPin($event)"
[matTooltip]="pinLabelKey() | translate"
[attr.aria-label]="pinLabelKey() | translate"
>
<mat-icon>push_pin</mat-icon>
</button>
}
<button
type="button"
class="source-row__action source-row__action--play"
(click)="onPlay($event)"
[matTooltip]="'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
</div>
</div>
@@ -0,0 +1,277 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: block;
// Feature-specific status colours. Light theme is the default (no
// .dark-theme class on body); the dark overrides follow, mirroring the
// pattern in libs/ui/styles/_detail-view.scss. The handoff's dark values
// are far too light on a white surface, so light theme uses darker
// equivalents that clear ~4.5:1 against the tinted tag background.
--vod-source-ok-fg: #15803d;
--vod-source-ok-bg: rgba(21, 128, 61, 0.1);
--vod-source-warn-fg: #b45309;
--vod-source-warn-bg: rgba(180, 83, 9, 0.1);
--vod-source-bad-fg: #b91c1c;
--vod-source-bad-bg: rgba(185, 28, 28, 0.1);
}
:host-context(.dark-theme) {
--vod-source-ok-fg: #4ade80;
--vod-source-ok-bg: rgba(74, 222, 128, 0.1);
--vod-source-warn-fg: #ffcf99;
--vod-source-warn-bg: rgba(255, 207, 153, 0.1);
--vod-source-bad-fg: #ff9aa6;
--vod-source-bad-bg: rgba(255, 91, 110, 0.1);
}
.source-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 10px;
box-sizing: border-box;
transition:
background 0.15s ease,
outline-color 0.15s ease;
&:hover {
background: var(--app-card-hover-bg);
}
&--active,
&--active:hover {
background: var(--app-selection-surface);
outline: 1px solid var(--app-selection-color);
outline-offset: -1px;
}
}
.source-row__monogram {
flex: none;
width: 28px;
height: 28px;
// Concentric with the row: outer 10px radius minus 3px inset.
border-radius: 7px;
display: grid;
place-items: center;
background: var(--app-card-hover-bg);
color: var(--app-eyebrow-color);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
}
.source-row__info {
flex: 1;
min-width: 0;
// One line, not three: the name takes what it needs, the tags sit on the
// horizontal space that was previously empty. Roughly halves row height,
// which doubles how many sources fit without touching the panel size.
display: flex;
align-items: center;
gap: 10px;
}
.source-row__name-line {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
// Shrinks before the tags do — the status tags are the deciding
// information, the label only has to stay recognizable.
flex: 1 1 auto;
}
.source-row__name {
font-size: 13px;
font-weight: 600;
line-height: 1.25;
color: var(--app-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-row__badge {
flex: none;
font-family: $font-mono;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--app-selection-color);
background: color-mix(in srgb, var(--app-selection-color) 12%, transparent);
border-radius: 4px;
padding: 2px 6px;
}
.source-row__tags {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 1 auto;
min-width: 0;
// Never wrap to a second line; overflowing tags are clipped from the left
// so the trailing status tag (the one that matters) always stays visible.
flex-wrap: nowrap;
overflow: hidden;
}
.source-tag {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
font-family: $font-mono;
font-weight: 500;
font-size: 10.5px;
line-height: 1.5;
border-radius: 4px;
padding: 2px 6px;
border: 1px solid transparent;
color: var(--app-eyebrow-color);
background: var(--app-card-hover-bg);
white-space: nowrap;
&--ok {
color: var(--vod-source-ok-fg);
background: var(--vod-source-ok-bg);
}
&--warn {
color: var(--vod-source-warn-fg);
background: var(--vod-source-warn-bg);
}
&--bad {
color: var(--vod-source-bad-fg);
background: var(--vod-source-bad-bg);
}
// The one tag the user can act on: no fill, dashed edge, so an unknown
// never reads as a value.
&--action {
border: 1px dashed var(--app-widget-border);
background: transparent;
color: var(--app-eyebrow-color);
cursor: pointer;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
&:hover {
color: var(--app-on-surface);
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 8%,
transparent
);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
}
.source-tag__icon {
font-size: 12px;
width: 12px;
height: 12px;
line-height: 12px;
}
.source-tag__spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
animation: vod-source-spin 0.7s linear infinite;
}
@keyframes vod-source-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.source-tag__spinner {
animation-duration: 2.4s;
}
}
.source-row__actions {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.source-row__action {
width: 30px;
height: 30px;
padding: 0;
box-sizing: border-box;
border-radius: 8px;
display: grid;
place-items: center;
border: 1px solid var(--app-widget-border);
background: transparent;
color: var(--app-eyebrow-color);
cursor: pointer;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
&:hover {
color: var(--app-on-surface);
background: var(--app-card-hover-bg);
}
&.is-active {
color: var(--app-selection-color);
border-color: var(--app-selection-color);
background: color-mix(
in srgb,
var(--app-selection-color) 12%,
transparent
);
}
&--play {
border-color: transparent;
background: var(--app-selection-color);
color: var(--mat-sys-on-primary, #fff);
&:hover {
background: color-mix(
in srgb,
var(--app-selection-color) 86%,
var(--app-on-surface)
);
color: var(--mat-sys-on-primary, #fff);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
}
@@ -0,0 +1,78 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import {
buildVodSourceTags,
sourceMonogram,
sourceNeedsCheck,
} from './vod-source-tags';
import { playlistDisplayLabel } from '@iptvnator/shared/interfaces';
/**
* One alternative source for the movie being viewed.
*
* Shared by the sources popover (details + player) and the playback error
* screen. It renders only what the descriptor can prove: unknown metadata
* produces no tag, just the actionable check chip.
*/
@Component({
selector: 'app-vod-source-row',
templateUrl: './vod-source-row.component.html',
styleUrls: ['./vod-source-row.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, MatTooltip, TranslatePipe],
})
export class VodSourceRowComponent {
readonly source = input.required<VodSourceDescriptor>();
/** The error screen lists alternatives without offering the pin action. */
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
/**
* Short, credential-safe label. Playlist names are routinely the pasted
* URL, which carries `username=`/`password=` — never render that raw.
*/
readonly displayLabel = computed(() =>
playlistDisplayLabel(
this.source().playlistName,
this.source().playlistId
)
);
// From the display label, not the raw name: every URL-named playlist would
// otherwise monogram to "HT" (from "http://") and be indistinguishable.
readonly monogram = computed(() => sourceMonogram(this.displayLabel()));
readonly tags = computed(() => buildVodSourceTags(this.source()));
readonly showCheckChip = computed(() => sourceNeedsCheck(this.source()));
readonly pinLabelKey = computed(() =>
this.source().isPinned
? 'PORTALS.MULTI_SOURCE.UNPIN_SOURCE'
: 'PORTALS.MULTI_SOURCE.PIN_SOURCE'
);
onPlay(event: Event): void {
event.stopPropagation();
this.playRequested.emit(this.source().id);
}
onPin(event: Event): void {
event.stopPropagation();
this.pinRequested.emit(this.source().id);
}
onCheck(event: Event): void {
event.stopPropagation();
this.checkRequested.emit(this.source().id);
}
}
@@ -0,0 +1,200 @@
import {
VodSourceDescriptor,
VodSourceField,
VodSourcePortalType,
} from '@iptvnator/shared/interfaces';
/**
* Presentation helpers for the VOD multi-source row.
*
* The whole feature rests on one rule: never present a guess as a fact. These
* helpers are the single place that turns a `VodSourceDescriptor` into tags, so
* the honesty rules cannot drift between the three surfaces that render rows
* (details popover, in-player popover, playback error screen).
*/
export type VodSourceTagTone = 'neutral' | 'ok' | 'warn' | 'bad';
export interface VodSourceRowTag {
/** Stable identity for `@for` tracking; also the tag's semantic slot. */
id: string;
tone: VodSourceTagTone;
/** A provider value, rendered verbatim — never translated. */
text?: string;
/** Translation key, used for app copy such as probe outcomes. */
labelKey?: string;
/** `~` marks a value that was guessed from the title rather than stated. */
prefix?: string;
/** Trailing detail such as `· 0.4s`. */
suffix?: string;
/** Material Symbol rendered before the label. */
icon?: string;
/** Renders an inline spinner instead of an icon. */
spinner?: boolean;
}
const PORTAL_TYPE_LABELS: Record<VodSourcePortalType, string> = {
xtream: 'Xtream',
stalker: 'Stalker',
m3u: 'M3U',
};
export const VOD_SOURCE_TAG_KEYS = {
probeOk: 'PORTALS.MULTI_SOURCE.PROBE_OK',
probeFail: 'PORTALS.MULTI_SOURCE.PROBE_FAIL',
probeChecking: 'PORTALS.MULTI_SOURCE.PROBE_CHECKING',
matchByTitle: 'PORTALS.MULTI_SOURCE.MATCH_BY_TITLE',
} as const;
/**
* `400` becomes `0.4s`. Returns `undefined` for anything that is not a usable
* measurement, so the caller renders no latency rather than `NaNs`.
*/
export function formatProbeLatency(latencyMs?: number): string | undefined {
if (
typeof latencyMs !== 'number' ||
!Number.isFinite(latencyMs) ||
latencyMs < 0
) {
return undefined;
}
return `${(latencyMs / 1000).toFixed(1)}s`;
}
/**
* `api` and `probe` values are facts and render as plain tags. A `parsed` value
* is a regex guess, so it is prefixed with `~` and carries the warn colour. An
* absent field renders nothing at all — the row offers the check chip instead.
*/
function fieldTag(
id: string,
field: VodSourceField | undefined
): VodSourceRowTag | null {
if (!field) {
return null;
}
const isGuess = field.provenance === 'parsed';
return {
id,
tone: isGuess ? 'warn' : 'neutral',
text: field.value,
prefix: isGuess ? '~' : undefined,
};
}
/**
* `idle` and `unknown` both mean "not checked". Neither may ever be shown as
* offline, so both produce no probe tag and let the check chip take the slot.
*/
function probeTag(source: VodSourceDescriptor): VodSourceRowTag | null {
const { status, latencyMs } = source.probe;
if (status === 'probing') {
return {
id: 'probe',
tone: 'neutral',
labelKey: VOD_SOURCE_TAG_KEYS.probeChecking,
spinner: true,
};
}
if (status === 'ok') {
const latency = formatProbeLatency(latencyMs);
return {
id: 'probe',
tone: 'ok',
icon: 'check',
labelKey: VOD_SOURCE_TAG_KEYS.probeOk,
suffix: latency ? `· ${latency}` : undefined,
};
}
if (status === 'fail') {
return {
id: 'probe',
tone: 'bad',
icon: 'close',
labelKey: VOD_SOURCE_TAG_KEYS.probeFail,
};
}
return null;
}
/**
* A fuzzy match is a guess about *which movie this is*, so it is flagged with
* the same `~` language as guessed metadata, and shows the year when known
* because that is what lets the user disambiguate.
*/
function matchTag(source: VodSourceDescriptor): VodSourceRowTag | null {
if (source.matchConfidence !== 'fuzzy') {
return null;
}
return {
id: 'match',
tone: 'warn',
prefix: '~',
labelKey: VOD_SOURCE_TAG_KEYS.matchByTitle,
suffix: source.year ? `· ${source.year}` : undefined,
};
}
/**
* Fixed order: portal type → quality → codec → container → audio → probe.
* The fuzzy-match flag sits just before the probe status so the trailing slot
* stays the availability one across every row.
*/
export function buildVodSourceTags(
source: VodSourceDescriptor
): VodSourceRowTag[] {
const tags: (VodSourceRowTag | null)[] = [
{
id: 'portal',
tone: 'neutral',
text: PORTAL_TYPE_LABELS[source.portalType] ?? source.portalType,
},
fieldTag('quality', source.quality),
fieldTag('codec', source.codec),
fieldTag('container', source.container),
fieldTag('audio', source.audio),
matchTag(source),
probeTag(source),
];
return tags.filter((tag): tag is VodSourceRowTag => tag !== null);
}
/**
* The check chip is offered whenever something is unknown — either the stream
* was never probed, or a metadata field is missing. It is deliberately a single
* chip rather than one per gap: the user asks once, and the check fills in what
* it can. While a probe is in flight the chip stands down for the spinner tag.
*/
export function sourceNeedsCheck(source: VodSourceDescriptor): boolean {
const status = source.probe.status;
if (status === 'probing') {
return false;
}
const isUnchecked = status === 'idle' || status === 'unknown';
const hasMissingMetadata =
!source.quality || !source.codec || !source.container || !source.audio;
return isUnchecked || hasMissingMetadata;
}
/** Portals have no logos, so the row falls back to a two-letter monogram. */
export function sourceMonogram(playlistName: string): string {
const trimmed = (playlistName ?? '').trim();
if (!trimmed) {
return '?';
}
return [...trimmed].slice(0, 2).join('').toUpperCase();
}
@@ -0,0 +1,49 @@
<button
type="button"
class="sources-chip"
[class.sources-chip--open]="isOpen()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
(click)="toggle()"
[attr.aria-expanded]="isOpen()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.OPEN_SOURCES' | translate"
>
<mat-icon class="sources-chip__icon">video_library</mat-icon>
<span class="sources-chip__label">{{
'PORTALS.MULTI_SOURCE.SOURCES' | translate
}}</span>
<span class="sources-chip__count">{{ displayCount() }}</span>
<mat-icon class="sources-chip__caret" [class.is-open]="isOpen()"
>expand_more</mat-icon
>
</button>
<!-- CDK Overlay rather than MatMenu: this is a rich panel, not a list of menu
items, and Material's menu caps its width at 280px in a way that fought
every override. Here the width is simply ours. -->
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayViewportMargin]="16"
(backdropClick)="close()"
(detach)="close()"
(overlayKeydown)="onOverlayKeydown($event)"
>
<app-vod-sources-menu
class="sources-chip__panel"
[sources]="sources()"
[title]="title()"
[matchKind]="matchKind()"
[autoFailoverEnabled]="autoFailoverEnabled()"
[resumeLabel]="resumeLabel()"
[showPin]="showPin()"
(playRequested)="onPlay($event)"
(pinRequested)="onPin($event)"
(checkRequested)="onCheck($event)"
(autoFailoverToggled)="autoFailoverToggled.emit($event)"
/>
</ng-template>
@@ -0,0 +1,78 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: inline-block;
}
.sources-chip {
// Layout plus a full reset of the UA button appearance. The surface itself
// is supplied by whichever bar hosts the chip, because "looks like its
// neighbours" means something different in the hero than in the player.
// Without the reset, a host that sets no background gets Chromium's grey
// `buttonface` default instead of nothing.
appearance: none;
background: transparent;
border: 0;
color: inherit;
padding: 0;
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
cursor: pointer;
font: inherit;
// Tactile press, matching the rest of the action row.
&:active {
transform: scale(0.985);
}
}
.sources-chip__icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
color: var(--app-eyebrow-color);
}
.sources-chip__count {
font-family: $font-mono;
font-weight: 700;
font-size: 11px;
line-height: 1.3;
border-radius: 999px;
padding: 2px 8px;
color: var(--app-selection-color);
background: color-mix(in srgb, var(--app-selection-color) 12%, transparent);
}
.sources-chip__caret {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
color: var(--app-eyebrow-color);
transition: transform 0.14s ease-out;
&.is-open {
transform: rotate(180deg);
}
}
// The popover surface. Width lives here, on an element we fully own, instead
// of being fought out with Material's 280px menu cap.
.sources-chip__panel {
display: block;
width: 620px;
max-width: calc(100vw - 32px);
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.35);
border-radius: 14px;
// Opaque here as well as on the inner surface: a CDK overlay floats over
// arbitrary page content, so a missing background is not a cosmetic bug —
// it makes the whole panel unreadable.
background: var(--app-widget-bg, #161a22);
}
@@ -0,0 +1,125 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
CdkOverlayOrigin,
CdkConnectedOverlay,
type ConnectedPosition,
} from '@angular/cdk/overlay';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
VodSourceDescriptor,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from './vod-sources-menu.component';
/**
* The "Sources N" chip on the VOD details action row, together with its
* anchored popover. Hosts render it only when at least one alternative source
* was found.
*/
@Component({
selector: 'app-vod-sources-chip',
templateUrl: './vod-sources-chip.component.html',
styleUrls: ['./vod-sources-chip.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CdkConnectedOverlay,
CdkOverlayOrigin,
MatIcon,
TranslatePipe,
VodSourcesMenuComponent,
],
})
export class VodSourcesChipComponent {
readonly sources = input.required<VodSourceDescriptor[]>();
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
/** Falls back to the number of sources when the host does not override it. */
readonly count = input<number | null>(null);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
readonly isOpen = signal(false);
readonly displayCount = computed(
() => this.count() ?? this.sources().length
);
/**
* Below the chip by default, flipping above when the action row sits near
* the bottom of the window. `viewportMargin` keeps it off the edges.
*/
readonly overlayPositions: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 8,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -8,
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
offsetY: 8,
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
offsetY: -8,
},
];
toggle(): void {
this.isOpen.update((open) => !open);
}
close(): void {
this.isOpen.set(false);
}
onOverlayKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
this.close();
}
}
/** Playing from a row hands the screen back to the player. */
onPlay(sourceId: string): void {
this.close();
this.playRequested.emit(sourceId);
}
/** Pinning is a decision, not a browse step — the popover steps aside. */
onPin(sourceId: string): void {
this.close();
this.pinRequested.emit(sourceId);
}
/** Checking stays open: the spinner and its outcome belong on the row. */
onCheck(sourceId: string): void {
this.checkRequested.emit(sourceId);
}
}
@@ -0,0 +1,135 @@
<div class="sources-menu">
<div class="sources-menu__header">
<h3 class="sources-menu__title">
@if (title()) {
{{
'PORTALS.MULTI_SOURCE.SOURCES_FOR'
| translate: { title: title() }
}}
} @else {
{{ 'PORTALS.MULTI_SOURCE.SOURCE' | translate }}
}
</h3>
<span class="sources-menu__hint">
@if (resumeLabel(); as resume) {
{{ resume }}
} @else {
{{ matchKindLabelKey() | translate }}
}
</span>
</div>
<div class="sources-menu__toolbar">
@if (showFilter()) {
<div class="sources-menu__filter">
<mat-icon class="sources-menu__filter-icon">search</mat-icon>
<input
type="text"
class="sources-menu__filter-input"
[ngModel]="filter()"
(ngModelChange)="filter.set($event)"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
[placeholder]="
'PORTALS.MULTI_SOURCE.FILTER_PLACEHOLDER' | translate
"
/>
</div>
}
@if (uncheckedCount() > 0) {
<button
type="button"
class="sources-menu__check-all"
(click)="checkAllVisible()"
>
<mat-icon>refresh</mat-icon>
<span>{{ 'PORTALS.MULTI_SOURCE.CHECK_ALL' | translate }}</span>
</button>
}
</div>
<div class="sources-menu__list">
@for (group of groups(); track group.key) {
<div class="sources-menu__group">
<app-vod-source-row
[source]="group.primary"
[showPin]="showPin()"
(playRequested)="playRequested.emit($event)"
(pinRequested)="pinRequested.emit($event)"
(checkRequested)="checkRequested.emit($event)"
/>
@if (group.count > 1) {
<button
type="button"
class="sources-menu__variants-toggle"
[attr.aria-expanded]="isExpanded(group.key)"
(click)="toggleGroup(group.key)"
>
<mat-icon>{{
isExpanded(group.key)
? 'expand_less'
: 'expand_more'
}}</mat-icon>
<span>{{
'PORTALS.MULTI_SOURCE.COPIES'
| translate: { count: group.count }
}}</span>
</button>
@if (isExpanded(group.key)) {
<div class="sources-menu__variants">
@for (
variant of group.sources;
track variant.id;
let i = $index
) {
@if (variant.id !== group.primary.id) {
<div class="sources-menu__variant">
<span
class="sources-menu__variant-label"
[title]="variantLabelFor(group, i)"
>{{ variantLabelFor(group, i) }}</span
>
<app-vod-source-row
[source]="variant"
[showPin]="showPin()"
(playRequested)="
playRequested.emit($event)
"
(pinRequested)="
pinRequested.emit($event)
"
(checkRequested)="
checkRequested.emit($event)
"
/>
</div>
}
}
</div>
}
}
</div>
}
@if (visibleSources().length === 0) {
<p class="sources-menu__empty">
{{ 'PORTALS.MULTI_SOURCE.NO_MATCHES' | translate }}
</p>
}
</div>
<div class="sources-menu__footer">
<span class="sources-menu__footer-label">{{
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
}}</span>
<mat-slide-toggle
class="sources-menu__toggle"
[checked]="autoFailoverEnabled()"
(change)="onAutoFailoverChange($event)"
(click)="$event.stopPropagation()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate"
/>
</div>
</div>
@@ -0,0 +1,220 @@
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
:host {
display: block;
}
// Self-sufficient surface: the popover body is also dropped straight into the
// playback error screen, where there is no menu panel to supply a background.
.sources-menu {
box-sizing: border-box;
padding: 8px;
border-radius: 14px;
background: var(--app-widget-bg, #161a22);
outline: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08));
color: var(--app-on-surface);
}
.sources-menu__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 12px 14px 10px;
}
.sources-menu__title {
margin: 0;
min-width: 0;
// Reads as the panel's heading. Previously it sat at the same weight and
// size as the hint and the action beside it, so all three scanned as
// column headers over the list.
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--app-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sources-menu__hint {
flex: none;
font-size: 11px;
line-height: 1.35;
color: var(--app-eyebrow-color);
}
.sources-menu__list {
display: flex;
flex-direction: column;
gap: 2px;
// Compact rows mean this now shows ~9 sources instead of ~3. Viewport-aware
// so a short window still gets a usable list rather than a clipped one.
max-height: min(56vh, 460px);
overflow-y: auto;
}
.sources-menu__footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 8px;
border-top: 1px solid var(--app-widget-border);
margin-top: 4px;
font-size: 11.5px;
color: var(--app-eyebrow-color);
}
.sources-menu__footer-label {
flex: 1;
min-width: 0;
line-height: 1.35;
}
.sources-menu__toggle {
flex: none;
margin-left: auto;
}
.sources-menu__check-all {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid var(--app-widget-border);
border-radius: 999px;
background: none;
color: var(--app-eyebrow-color);
font: inherit;
font-size: 11px;
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
}
.sources-menu__filter {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 6px;
padding: 6px 10px;
border: 1px solid var(--app-widget-border);
// Concentric with the row radius one level in.
border-radius: 8px;
background: var(--app-card-hover-bg);
&:focus-within {
border-color: var(--app-selection-color);
}
}
.sources-menu__filter-icon {
flex: none;
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
color: var(--app-eyebrow-color);
}
.sources-menu__filter-input {
flex: 1;
min-width: 0;
border: 0;
background: none;
outline: none;
color: var(--app-on-surface);
font: inherit;
font-size: 12.5px;
&::placeholder {
color: var(--app-eyebrow-color);
}
}
.sources-menu__empty {
margin: 0;
padding: 18px 12px;
text-align: center;
color: var(--app-eyebrow-color);
font-size: 12.5px;
}
// Controls live on their own line, below the heading, so nothing in the header
// can be mistaken for a column label.
.sources-menu__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 14px 10px;
}
.sources-menu__toolbar .sources-menu__filter {
flex: 1;
min-width: 0;
margin: 0;
}
.sources-menu__toolbar .sources-menu__check-all {
flex: none;
margin-left: auto;
}
.sources-menu__variants-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
// Aligned under the row's info block, past the monogram, so it reads as
// belonging to the row above rather than as a list item of its own.
margin: 0 0 2px 48px;
padding: 2px 8px;
border: 0;
border-radius: 999px;
background: none;
color: var(--app-eyebrow-color);
font: inherit;
font-size: 11px;
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg);
color: var(--app-on-surface);
}
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
}
.sources-menu__variants {
// A left rail is the cheapest way to show these belong to the row above
// without nesting another card inside the panel.
margin: 0 0 4px 20px;
padding-left: 12px;
border-left: 1px solid var(--app-widget-border, rgba(255, 255, 255, 0.08));
}
.sources-menu__variant-label {
display: block;
padding: 2px 10px 0;
color: var(--app-eyebrow-color);
font-size: 10.5px;
font-family: $font-mono;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,264 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { VodSourceDescriptor } from '@iptvnator/shared/interfaces';
import { VodSourcesMenuComponent } from './vod-sources-menu.component';
class FakeTranslateLoader implements TranslateLoader {
getTranslation(): Observable<Record<string, unknown>> {
return of({
PORTALS: {
MULTI_SOURCE: {
SOURCE: 'Source',
SOURCES_FOR: 'Sources for “{{title}}”',
MATCHED_BY_TMDB: 'matched by TMDB',
MATCHED_BY_TITLE: 'matched by title',
MATCH_BY_TITLE: 'by title',
AUTO_FAILOVER: 'Auto-switch on error',
PLAYING: 'Playing',
PLAY_FROM_SOURCE: 'Play from this source',
PIN_SOURCE: 'Make this the main source',
UNPIN_SOURCE: 'Unpin this source',
CHECK: 'check',
CHECK_TOOLTIP: 'Check availability',
PROBE_OK: 'available',
PROBE_FAIL: 'unavailable',
PROBE_CHECKING: 'checking',
OPEN_SOURCES: 'Show alternative sources',
},
},
});
}
}
function createSource(
overrides: Partial<VodSourceDescriptor> = {}
): VodSourceDescriptor {
return {
id: 'playlist-1:xtream:42',
playlistId: 'playlist-1',
playlistName: 'SharkTV',
portalType: 'xtream',
contentId: 42,
rawTitle: 'Dune Part Two 2160p',
matchConfidence: 'exact',
year: 2024,
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
...overrides,
};
}
describe('VodSourcesMenuComponent', () => {
let fixture: ComponentFixture<VodSourcesMenuComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
VodSourcesMenuComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: FakeTranslateLoader,
},
}),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setDefaultLang('en');
translate.use('en');
fixture = TestBed.createComponent(VodSourcesMenuComponent);
});
function render(sources: VodSourceDescriptor[]): void {
fixture.componentRef.setInput('sources', sources);
fixture.detectChanges();
}
/** Metadata tags only — the actionable check chip is not a value. */
function tagTexts(): string[] {
return fixture.debugElement
.queryAll(By.css('.source-tag:not(.source-tag--action)'))
.map((tag) => (tag.nativeElement.textContent ?? '').trim());
}
function checkChip() {
return fixture.debugElement.query(By.css('.source-tag--action'));
}
it('renders a stated value as a plain tag, without the guess marker', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
}),
]);
const qualityTag = tagTexts().find((text) => text.includes('1080p'));
expect(qualityTag).toBeDefined();
expect(qualityTag).not.toContain('~');
expect(
fixture.debugElement.queryAll(By.css('.source-tag--warn')).length
).toBe(0);
});
it('marks a parsed value as a guess with ~ and the warn tone', () => {
render([
createSource({
quality: { value: '2160p', provenance: 'parsed' },
}),
]);
const warnTags = fixture.debugElement.queryAll(
By.css('.source-tag--warn')
);
expect(warnTags.length).toBe(1);
const text = (warnTags[0].nativeElement.textContent ?? '').trim();
expect(text).toContain('~');
expect(text).toContain('2160p');
});
it('renders no tag for an unknown field and offers the check chip instead', () => {
render([
createSource({
quality: undefined,
codec: undefined,
container: { value: 'mkv', provenance: 'api' },
audio: undefined,
probe: { status: 'ok', latencyMs: 120 },
}),
]);
const texts = tagTexts().join(' | ');
expect(texts).not.toContain('unknown');
expect(texts).not.toContain('—');
// Portal type, the one known field, and the probe outcome — nothing else.
expect(tagTexts().length).toBe(3);
expect(checkChip()).not.toBeNull();
});
it('never renders an unchecked probe as offline and keeps offering the check', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
codec: { value: 'h264', provenance: 'api' },
container: { value: 'mkv', provenance: 'api' },
audio: { value: 'rus', provenance: 'api' },
probe: { status: 'unknown' },
}),
]);
expect(
fixture.debugElement.queryAll(By.css('.source-tag--bad')).length
).toBe(0);
expect(tagTexts().join(' | ')).not.toContain('unavailable');
expect(checkChip()).not.toBeNull();
});
it('renders a successful probe with its latency in seconds', () => {
render([
createSource({
quality: { value: '1080p', provenance: 'api' },
codec: { value: 'h264', provenance: 'api' },
container: { value: 'mkv', provenance: 'api' },
audio: { value: 'rus', provenance: 'api' },
probe: { status: 'ok', latencyMs: 400 },
}),
]);
const okTag = fixture.debugElement.query(By.css('.source-tag--ok'));
expect(okTag).not.toBeNull();
const text = (okTag.nativeElement.textContent ?? '').trim();
expect(text).toContain('available');
expect(text).toContain('0.4s');
// Nothing left unknown, so the check chip stands down.
expect(checkChip()).toBeNull();
});
it('orders tags portal type → quality → codec → container → audio → probe', () => {
render([
createSource({
quality: { value: '2160p', provenance: 'api' },
codec: { value: 'hevc', provenance: 'api' },
container: { value: 'mp4', provenance: 'api' },
audio: { value: 'dub', provenance: 'api' },
probe: { status: 'ok', latencyMs: 400 },
}),
]);
const texts = tagTexts();
expect(texts[0]).toContain('Xtream');
expect(texts[1]).toContain('2160p');
expect(texts[2]).toContain('hevc');
expect(texts[3]).toContain('mp4');
expect(texts[4]).toContain('dub');
expect(texts[5]).toContain('available');
expect(texts.length).toBe(6);
});
it('flags a fuzzy match with ~ and the year that disambiguates it', () => {
render([
createSource({
matchConfidence: 'fuzzy',
year: 2024,
quality: { value: '720p', provenance: 'api' },
}),
]);
const warnTag = fixture.debugElement.query(By.css('.source-tag--warn'));
expect(warnTag).not.toBeNull();
const text = (warnTag.nativeElement.textContent ?? '').trim();
expect(text).toContain('~');
expect(text).toContain('by title');
expect(text).toContain('2024');
});
it('emits the toggled state when the footer switch is flipped', () => {
render([createSource()]);
const emitted: boolean[] = [];
fixture.componentInstance.autoFailoverToggled.subscribe(
(value: boolean) => emitted.push(value)
);
const toggle = fixture.debugElement.query(
By.css('.sources-menu__toggle button[role="switch"]')
);
expect(toggle).not.toBeNull();
toggle.nativeElement.click();
fixture.detectChanges();
expect(emitted).toEqual([true]);
});
it('shows the match kind in the header, or the resume label when given', () => {
fixture.componentRef.setInput('sources', [createSource()]);
fixture.componentRef.setInput('matchKind', 'tmdb');
fixture.detectChanges();
const hint = fixture.debugElement.query(By.css('.sources-menu__hint'));
expect(hint.nativeElement.textContent.trim()).toBe('matched by TMDB');
fixture.componentRef.setInput(
'resumeLabel',
'timecode will be kept · 0:42:18'
);
fixture.detectChanges();
expect(hint.nativeElement.textContent.trim()).toBe(
'timecode will be kept · 0:42:18'
);
});
});
@@ -0,0 +1,155 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIcon } from '@angular/material/icon';
import {
MatSlideToggle,
MatSlideToggleChange,
} from '@angular/material/slide-toggle';
import { TranslatePipe } from '@ngx-translate/core';
import {
playlistDisplayLabel,
VodSourceDescriptor,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from './vod-source-row.component';
import {
groupVodSources,
variantLabel,
type VodSourceGroup,
} from './vod-source-groups.util';
/** Below this many sources, a filter box costs more than it saves. */
const FILTER_FROM = 8;
/**
* Body of the "sources" popover: header, the source rows, and the auto-failover
* footer. Rendered inside the chip's anchored menu on the details page and
* reused as-is by the in-player popover and the playback error screen, so it
* owns no anchoring of its own.
*/
@Component({
selector: 'app-vod-sources-menu',
templateUrl: './vod-sources-menu.component.html',
styleUrls: ['./vod-sources-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
MatIcon,
MatSlideToggle,
TranslatePipe,
VodSourceRowComponent,
],
})
export class VodSourcesMenuComponent {
readonly sources = input.required<VodSourceDescriptor[]>();
/**
* Movie title for the header. Empty renders the bare "Source" heading used
* by the in-player variant, where the title is already on screen.
*/
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
/**
* In-player variant: replaces the match kind with e.g.
* "timecode will be kept · 0:42:18". Already formatted by the host.
*/
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
readonly playRequested = output<string>();
readonly pinRequested = output<string>();
readonly checkRequested = output<string>();
readonly autoFailoverToggled = output<boolean>();
/** Filter text; only reachable once the list is long enough to need it. */
readonly filter = signal('');
/**
* Progressive disclosure: a search box for four sources is chrome nobody
* asked for, but scanning eleven by eye is worse.
*/
readonly showFilter = computed(() => this.sources().length > FILTER_FROM);
readonly visibleSources = computed(() => {
const query = this.filter().trim().toLowerCase();
const sources = this.sources();
if (!query || !this.showFilter()) {
return sources;
}
return sources.filter((source) =>
playlistDisplayLabel(source.playlistName, source.playlistId)
.toLowerCase()
.includes(query)
);
});
/**
* Sources collapsed per playlist. One playlist often lists the same film
* several times; flat they are indistinguishable rows.
*/
readonly groups = computed(() => groupVodSources(this.visibleSources()));
private readonly expanded = signal<ReadonlySet<string>>(new Set());
isExpanded(key: string): boolean {
return this.expanded().has(key);
}
toggleGroup(key: string): void {
this.expanded.update((keys) => {
const next = new Set(keys);
if (!next.delete(key)) {
next.add(key);
}
return next;
});
}
/** Distinguishes copies within a group — usually the provider's own title. */
variantLabelFor(group: VodSourceGroup, index: number): string {
return variantLabel(group.sources[index], group.sources);
}
/** Anything still worth probing — drives the "check all" affordance. */
readonly uncheckedCount = computed(
() =>
this.visibleSources().filter(
(source) =>
source.probe.status === 'idle' ||
source.probe.status === 'unknown'
).length
);
readonly matchKindLabelKey = computed(() =>
this.matchKind() === 'tmdb'
? 'PORTALS.MULTI_SOURCE.MATCHED_BY_TMDB'
: 'PORTALS.MULTI_SOURCE.MATCHED_BY_TITLE'
);
onAutoFailoverChange(event: MatSlideToggleChange): void {
this.autoFailoverToggled.emit(event.checked);
}
/**
* Checking eleven sources one row at a time is busywork the app can do
* itself; the probe service already dedupes and caches.
*/
checkAllVisible(): void {
for (const source of this.visibleSources()) {
if (
source.probe.status === 'idle' ||
source.probe.status === 'unknown'
) {
this.checkRequested.emit(source.id);
}
}
}
}
@@ -24,8 +24,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -27,6 +27,19 @@
</h3>
</div>
<div class="player-shell__actions">
@if (allSources().length > 1) {
<app-vod-sources-chip
class="player-shell__sources"
[sources]="allSources()"
[count]="alternativeSources().length"
[resumeLabel]="sourcesResumeLabel()"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(pinRequested)="sourcePinRequested.emit($event)"
(checkRequested)="sourceCheckRequested.emit($event)"
/>
}
<button
mat-button
type="button"
@@ -68,6 +81,11 @@
[playback]="playback()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
[alternativeSources]="alternativeSources()"
(alternativeSourceRequested)="
alternativeSourceRequested.emit($event)
"
(playbackFailed)="playbackFailed.emit($event)"
(timeUpdate)="onTimeUpdate($event)"
(playbackEnded)="onPlaybackEnded()"
(previousEpisodeRequested)="onPreviousEpisodeRequested()"
@@ -75,6 +75,30 @@
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
align-items: center;
}
// The sources chip sits between a plain text button (Copy Stream URL) and an
// outlined one (Close player). It is a peer of the latter, so it borrows that
// shape — outlined pill, transparent fill, Material's 40px button height —
// rather than carrying the filled surface it wears in the detail hero.
.player-shell__actions ::ng-deep app-vod-sources-chip .sources-chip {
min-height: 40px;
padding: 0 16px;
border: 1px solid var(--mat-sys-outline, rgb(255 255 255 / 24%));
border-radius: 20px;
background: transparent;
color: inherit;
font-size: 0.875rem;
font-weight: 500;
transition:
background-color 140ms ease,
border-color 140ms ease;
&:hover {
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 38%);
}
}
// The viewport is the "theater stage": it spans the full content width and,
@@ -25,8 +25,11 @@ class StubWebPlayerViewComponent {
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly alternativeSourceRequested = output<string>();
readonly playbackFailed = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -19,8 +19,10 @@ import {
PlayerContentInfo,
ResolvedPortalPlayback,
VideoPlayer,
type VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import type { PlayerMediaTitle } from '../player-controls';
@@ -29,6 +31,7 @@ import type {
SeriesEpisodeMetadata,
SeriesPlaybackNavigation,
} from './series-playback-navigation';
import { VodSourcesChipComponent } from '@iptvnator/ui/components';
import { UpNextRailComponent } from './up-next-rail.component';
import type { UpNextRailItem } from './up-next-rail.util';
@@ -49,6 +52,7 @@ const RAIL_STAGE_GAP = 18;
MatTooltipModule,
TranslateModule,
UpNextRailComponent,
VodSourcesChipComponent,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -201,6 +205,17 @@ export class PortalInlinePlayerComponent {
}>();
readonly streamUrlCopied = output<void>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
/** Alternatives offered on the playback-error screen. */
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
/** Every source incl. the playing one — powers the in-player picker. */
readonly allSources = input<VodSourceDescriptor[]>([]);
/** Formatted resume timecode shown in the picker header. */
readonly sourcesResumeLabel = input<string | null>(null);
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly alternativeSourceRequested = output<string>();
/** Relayed playback failure, so hosts can offer/auto-pick a source. */
readonly playbackFailed = output<PlaybackDiagnosticCode>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
@@ -127,6 +127,36 @@
</div>
}
@if (alternativeSources().length > 0) {
<div
class="web-player-diagnostic__alternatives"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</h3>
@for (source of visibleAlternatives(); track source.id) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="alternativeSourceRequested.emit($event)"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: { count: hiddenAlternativeCount() }
}}
</p>
}
</div>
}
@if (canShowExternalFallbackActions()) {
<div
class="web-player-diagnostic__player-actions"
@@ -118,6 +118,41 @@ app-html-video-player {
overflow-wrap: anywhere;
}
// "Try another source" — the alternatives block that turns the error screen
// into a recovery point. The overlay is always dark, so these are literals
// rather than theme tokens, matching the rest of this diagnostic surface.
.web-player-diagnostic__alternatives {
width: min(420px, 100%);
margin: 20px auto 0;
padding: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
background: rgb(22 27 36 / 90%);
text-align: left;
// Scrolls itself rather than growing the whole overlay: otherwise a long
// list pushes the error headline and message off the top of the screen,
// which is precisely the information the user needs first.
max-height: min(46vh, 320px);
overflow-y: auto;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.web-player-diagnostic__player-actions {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more