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

@@ -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: