mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)
Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
This commit is contained in:
1 parent
e53adcbeaf
commit
b3e130aa65
56 files changed
+3044
-63
No files matched your search
@@ -1596,6 +1596,15 @@ export async function expectWorkspaceSearchStatus(
|
||||
).toHaveText(expected);
|
||||
}
|
||||
|
||||
/** The degraded-search hint chip must be absent (e.g. complete local search). */
|
||||
export async function expectNoWorkspaceSearchStatus(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .search-chip--status')
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function startPortalDebugCapture(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const electronApi = window.electron as typeof window.electron & {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
closeElectronApp,
|
||||
defaultStalkerMacAddress,
|
||||
expect,
|
||||
expectNoWorkspaceSearchStatus,
|
||||
expectWorkspaceSearchScope,
|
||||
expectWorkspaceSearchStatus,
|
||||
fillWorkspaceSearch,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
@@ -987,7 +987,7 @@ test.describe('Electron Workspace Search', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@search @stalker shows loaded-only scope and filters channels on ITV routes', async ({
|
||||
test('@search @stalker searches the complete channel list on ITV routes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -1030,26 +1030,22 @@ test.describe('Electron Workspace Search', () => {
|
||||
app.mainWindow,
|
||||
`Live TV / ${sample.categoryName}`
|
||||
);
|
||||
await expectWorkspaceSearchStatus(
|
||||
app.mainWindow,
|
||||
'Loaded channels only'
|
||||
);
|
||||
// Entering Live TV preloads the COMPLETE channel list (Ministra
|
||||
// get_all_channels), so search runs locally over every channel:
|
||||
// no portal-side search request, and no "Loaded channels only"
|
||||
// degraded hint.
|
||||
await waitForPortalDebugEvent(app.mainWindow, {
|
||||
provider: 'stalker',
|
||||
operation: 'get_ordered_list',
|
||||
operation: 'get_all_channels',
|
||||
predicate: (event) => {
|
||||
const requestPayload = event.request as {
|
||||
params?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
return (
|
||||
requestPayload.params?.['type'] === 'itv' &&
|
||||
String(requestPayload.params?.['category']) ===
|
||||
sample.categoryId &&
|
||||
requestPayload.params?.['search'] === itvQuery
|
||||
);
|
||||
return requestPayload.params?.['type'] === 'itv';
|
||||
},
|
||||
});
|
||||
await expectNoWorkspaceSearchStatus(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
|
||||
).toBeVisible();
|
||||
|
||||
@@ -38,6 +38,7 @@ Then in IPTVnator, add a new Stalker portal:
|
||||
| `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) |
|
||||
| `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow |
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
## Configuration
|
||||
@@ -65,6 +66,7 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
|
||||
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
|
||||
| `get_genres` | Genre list (mirrors categories) |
|
||||
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
|
||||
| `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario |
|
||||
| `create_link` | Returns a real public HLS stream URL for playback |
|
||||
| `favorites` | Add / remove / get favorites (in-memory, resets on restart) |
|
||||
| `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) |
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface RawCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
alias: string;
|
||||
/** Ministra adult-genre flag ('1' = censored). */
|
||||
censored?: string;
|
||||
}
|
||||
|
||||
export interface RawChannel {
|
||||
@@ -171,6 +173,16 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
|
||||
|
||||
// ------ ITV categories + channels ------
|
||||
data.itvCategories = generateCategories('itv', config.categoryCount.itv);
|
||||
// Real Ministra portals mark adult genres as censored and EXCLUDE their
|
||||
// channels from get_all_channels / the "*" listing; the channels are only
|
||||
// served by paging the specific genre. Mirror that with one extra
|
||||
// censored category so clients can exercise the fallback path.
|
||||
data.itvCategories.push({
|
||||
id: '1099',
|
||||
title: 'For adults',
|
||||
alias: 'for_adults',
|
||||
censored: '1',
|
||||
});
|
||||
let channelIndex = 0;
|
||||
for (const cat of data.itvCategories) {
|
||||
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getPortalData } from '../data-store.js';
|
||||
import { getScenario } from '../scenarios.js';
|
||||
import { extractMac } from './get-categories.handler.js';
|
||||
|
||||
/**
|
||||
* Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list
|
||||
* in a single response (no pagination). This is what STB clients use to build
|
||||
* their local channel list.
|
||||
*
|
||||
* Only type=itv is supported, matching the reference Ministra middleware.
|
||||
* Scenarios with supportsGetAllChannels=false mimic legacy portals and answer
|
||||
* with an error payload so clients fall back to paginated get_ordered_list.
|
||||
*/
|
||||
export function handleGetAllChannels(req: Request, res: Response): void {
|
||||
const mac = extractMac(req);
|
||||
const type = (req.query['type'] as string) ?? 'itv';
|
||||
const scenario = getScenario(mac);
|
||||
|
||||
if (scenario.supportsGetAllChannels === false || type !== 'itv') {
|
||||
res.json({ js: { error: 'Unknown action: get_all_channels' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = getPortalData(mac);
|
||||
// Like real Ministra portals: censored (adult) genres are excluded from
|
||||
// the bulk channel list — clients must page those genres explicitly.
|
||||
const censoredIds = new Set(
|
||||
data.itvCategories
|
||||
.filter((cat) => cat.censored === '1')
|
||||
.map((cat) => cat.id)
|
||||
);
|
||||
const allChannels = [...data.channels.entries()]
|
||||
.filter(([categoryId]) => !censoredIds.has(categoryId))
|
||||
.flatMap(([, channels]) => channels);
|
||||
|
||||
res.json({
|
||||
js: {
|
||||
data: allChannels,
|
||||
total_items: allChannels.length,
|
||||
max_page_items: allChannels.length,
|
||||
cur_page: 0,
|
||||
selected_item: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function handleGetGenres(req: Request, res: Response): void {
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
alias: cat.alias,
|
||||
censored: '0',
|
||||
censored: cat.censored ?? '0',
|
||||
}));
|
||||
|
||||
res.json({ js: genres });
|
||||
|
||||
@@ -34,8 +34,17 @@ export function handleGetOrderedList(req: Request, res: Response): void {
|
||||
|
||||
if (type === 'itv') {
|
||||
if (categoryId === '*') {
|
||||
for (const items of data.channels.values()) {
|
||||
allItems.push(...items);
|
||||
// The "*" listing excludes censored (adult) genres, matching
|
||||
// real portals; their channels are only served by genre id.
|
||||
const censoredIds = new Set(
|
||||
data.itvCategories
|
||||
.filter((cat) => cat.censored === '1')
|
||||
.map((cat) => cat.id)
|
||||
);
|
||||
for (const [catId, items] of data.channels.entries()) {
|
||||
if (!censoredIds.has(catId)) {
|
||||
allItems.push(...items);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allItems = data.channels.get(categoryId) ?? [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { handleHandshake } from '../handlers/handshake.handler.js';
|
||||
import { handleDoAuth } from '../handlers/do-auth.handler.js';
|
||||
import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js';
|
||||
import { handleGetCategories } from '../handlers/get-categories.handler.js';
|
||||
import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js';
|
||||
import { handleGetSeasons } from '../handlers/get-seasons.handler.js';
|
||||
@@ -39,6 +40,9 @@ export default function dispatchPortalAction(req: Request, res: Response): void
|
||||
handleGetOrderedList(req, res);
|
||||
}
|
||||
break;
|
||||
case 'get_all_channels':
|
||||
handleGetAllChannels(req, res);
|
||||
break;
|
||||
case 'create_link':
|
||||
handleCreateLink(req, res);
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface ScenarioConfig {
|
||||
isSeriesFraction: number;
|
||||
/** Fraction of VOD items that have embedded series[] array */
|
||||
embeddedSeriesFraction: number;
|
||||
/**
|
||||
* Whether the portal implements the ITV `get_all_channels` action.
|
||||
* Defaults to true; set false to force clients onto paginated
|
||||
* `get_ordered_list` crawling (legacy portals).
|
||||
*/
|
||||
supportsGetAllChannels?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +94,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[]
|
||||
},
|
||||
'00:1a:79:00:00:06': {
|
||||
name: 'legacy-pagination',
|
||||
description:
|
||||
'Portal without get_all_channels — clients must crawl get_ordered_list pages',
|
||||
seed: 6006,
|
||||
categoryCount: { itv: 6, radio: 4, vod: 4, series: 4 },
|
||||
itemsPerCategory: 40,
|
||||
seasonsPerSeries: 2,
|
||||
episodesPerSeason: 5,
|
||||
isSeriesFraction: 0,
|
||||
embeddedSeriesFraction: 0,
|
||||
supportsGetAllChannels: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,10 +5,13 @@ interface ProviderTargetPayload {
|
||||
}
|
||||
|
||||
const BACKEND_ORIGIN = 'http://localhost:3000';
|
||||
const APP_ORIGIN = process.env['BASE_URL']
|
||||
? new URL(process.env['BASE_URL']).origin
|
||||
: 'http://localhost:4200';
|
||||
const CORS_HEADERS = {
|
||||
'access-control-allow-headers': 'content-type',
|
||||
'access-control-allow-methods': 'POST, OPTIONS',
|
||||
'access-control-allow-origin': 'http://localhost:4200',
|
||||
'access-control-allow-origin': APP_ORIGIN,
|
||||
} as const;
|
||||
|
||||
export async function interceptProviderTargetRegistration(
|
||||
|
||||
@@ -35,6 +35,9 @@ const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
|
||||
const MINIMAL_MAC = '00:1A:79:00:00:03';
|
||||
|
||||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -333,6 +336,177 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({
|
||||
expect(shortEpgRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV full channel list loads via get_all_channels and search covers it', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const allChannelsRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('action=get_all_channels')) {
|
||||
allChannelsRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// BEFORE any category click (Xtream parity): entering the Live TV section
|
||||
// preloads the full list, so the main area shows the paginated
|
||||
// all-channels grid and the categories already carry count badges.
|
||||
const allItemsGrid = page.locator('app-stalker-itv-all-items');
|
||||
await expect(allItemsGrid.locator('mat-card').first()).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(
|
||||
allItemsGrid.locator('.mat-mdc-paginator-range-label')
|
||||
).toContainText('of 320');
|
||||
await expect(categories.nth(0).locator('.item-count')).toHaveText('320', {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40');
|
||||
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Regression for "search only finds the first 14 loaded items": once the
|
||||
// full list is cached, the whole category (40 channels) is available
|
||||
// without scrolling through 14-item pages.
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||||
|
||||
// Regression: switching to another category once the full list is cached
|
||||
// must serve that category from the cache, not get stuck on an empty
|
||||
// skeleton. (The reset-on-category-change effect used to clobber the
|
||||
// synchronously served list.)
|
||||
await categories.nth(2).click();
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||||
// No further get_all_channels request — it's served from the session cache.
|
||||
const requestsAfterFirstCategory = allChannelsRequests.length;
|
||||
await categories.nth(3).click();
|
||||
await expect(channels.first()).toBeVisible({ timeout: 10_000 });
|
||||
expect(allChannelsRequests.length).toBe(requestsAfterFirstCategory);
|
||||
|
||||
// Back to the first category for the search assertions below.
|
||||
await categories.nth(1).click();
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// Search a channel from deep in the list (beyond the first 14 items).
|
||||
const deepChannelName = (
|
||||
await channels.nth(30).locator('.channel-name').textContent()
|
||||
)?.trim();
|
||||
expect(deepChannelName).toBeTruthy();
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill(deepChannelName as string);
|
||||
await searchInput.press('Enter');
|
||||
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test-id="channel-item"] .channel-name')
|
||||
.filter({ hasText: deepChannelName as string })
|
||||
.first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// The "loaded only" degraded-search hint must be gone in full-list mode.
|
||||
await expect(page.locator('.search-chip--status')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV censored category pages from the portal and hides its badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page);
|
||||
|
||||
const adultListRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (
|
||||
url.includes('action=get_ordered_list') &&
|
||||
url.includes('type=itv') &&
|
||||
url.includes('genre=1099')
|
||||
) {
|
||||
adultListRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
// Wait until the full list is cached (a regular category shows its badge).
|
||||
await expect(categories.nth(1).locator('.item-count')).toHaveText('40', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The censored genre is excluded from get_all_channels, so its real count
|
||||
// is unknown — no badge instead of a misleading "0".
|
||||
const adultCategory = page.locator('.category-item', {
|
||||
hasText: 'For adults',
|
||||
});
|
||||
await expect(adultCategory).toBeVisible();
|
||||
await expect(adultCategory.locator('.item-count')).toHaveCount(0);
|
||||
|
||||
// Clicking it falls back to the legacy paged flow and still shows channels.
|
||||
await adultCategory.click();
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(() => adultListRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('@stalker ITV falls back to page crawling on portals without get_all_channels', async ({
|
||||
page,
|
||||
}) => {
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Legacy Stalker Portal',
|
||||
mac: LEGACY_PAGINATION_MAC,
|
||||
});
|
||||
|
||||
const allChannelsRequests: string[] = [];
|
||||
const crawlRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('action=get_all_channels')) {
|
||||
allChannelsRequests.push(url);
|
||||
}
|
||||
// The full-list crawl pages through ALL genres (genre=*).
|
||||
if (
|
||||
url.includes('action=get_ordered_list') &&
|
||||
url.includes('type=itv') &&
|
||||
(url.includes('genre=*') || url.includes('genre=%2A'))
|
||||
) {
|
||||
crawlRequests.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('link', { name: /live|itv/i }).click();
|
||||
await page.waitForURL(/stalker.*itv/);
|
||||
|
||||
const categories = page.locator('.category-item');
|
||||
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
|
||||
await categories.nth(1).click();
|
||||
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// The crawl collects all 6 × 40 channels; the selected category then
|
||||
// shows its full 40 items without manual lazy-load scrolling.
|
||||
await expect(page.locator('.category-subtitle')).toHaveText('40 items', {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0);
|
||||
expect(crawlRequests.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||||
// Generate data for default MAC
|
||||
const before = await request.get(
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
|
||||
"PLAYBACK_ERROR": "فشل الحصول على رابط التشغيل",
|
||||
"LOADING_SEASONS": "جارٍ تحميل المواسم...",
|
||||
"LOADING_ALL_CHANNELS": "جارٍ تحميل قائمة القنوات الكاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث قائمة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "لا توجد قنوات في هذه الفئة",
|
||||
"ITEM": "عنصر",
|
||||
"ITEMS": "عناصر",
|
||||
"NO_EPISODES": "لا توجد حلقات متاحة",
|
||||
"ALL_RADIO": "كل الراديو"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
|
||||
"PLAYBACK_ERROR": "فشل جلب رابط التشغيل",
|
||||
"LOADING_SEASONS": "جاري تشريج المواسم...",
|
||||
"LOADING_ALL_CHANNELS": "كيتم تحميل لائحة القنوات كاملة…",
|
||||
"REFRESH_CHANNEL_LIST": "تحديث لائحة القنوات",
|
||||
"NO_CHANNELS_IN_CATEGORY": "ماكاينش قنوات فهاد الصنف",
|
||||
"ITEM": "عنصر",
|
||||
"ITEMS": "عناصر",
|
||||
"NO_EPISODES": "ما كاينش حلقات متوفرة",
|
||||
"ALL_RADIO": "All radio"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
|
||||
"PLAYBACK_ERROR": "Не ўдалося атрымаць URL для прайгравання",
|
||||
"LOADING_SEASONS": "Загрузка сезонаў...",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка поўнага спісу каналаў…",
|
||||
"REFRESH_CHANNEL_LIST": "Абнавіць спіс каналаў",
|
||||
"NO_CHANNELS_IN_CATEGORY": "У гэтай катэгорыі няма каналаў",
|
||||
"ITEM": "элемент",
|
||||
"ITEMS": "элементаў",
|
||||
"NO_EPISODES": "Няма даступных эпізодаў",
|
||||
"ALL_RADIO": "Усе радыё"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
|
||||
"PLAYBACK_ERROR": "Wiedergabe-URL konnte nicht abgerufen werden",
|
||||
"LOADING_SEASONS": "Staffeln werden geladen …",
|
||||
"LOADING_ALL_CHANNELS": "Vollständige Senderliste wird geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Senderliste aktualisieren",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Keine Sender in dieser Kategorie",
|
||||
"ITEM": "Element",
|
||||
"ITEMS": "Elemente",
|
||||
"NO_EPISODES": "Keine Episoden verfügbar",
|
||||
"ALL_RADIO": "Alle Radiosender"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
|
||||
"PLAYBACK_ERROR": "Αποτυχία λήψης διεύθυνσης URL αναπαραγωγής",
|
||||
"LOADING_SEASONS": "Φόρτωση σεζόν...",
|
||||
"LOADING_ALL_CHANNELS": "Φόρτωση πλήρους λίστας καναλιών…",
|
||||
"REFRESH_CHANNEL_LIST": "Ανανέωση λίστας καναλιών",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Δεν υπάρχουν κανάλια σε αυτήν την κατηγορία",
|
||||
"ITEM": "στοιχείο",
|
||||
"ITEMS": "στοιχεία",
|
||||
"NO_EPISODES": "Δεν υπάρχουν διαθέσιμα επεισόδια",
|
||||
"ALL_RADIO": "Όλα τα ραδιόφωνα"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
"PLAYBACK_ERROR": "Failed to get playback URL",
|
||||
"LOADING_SEASONS": "Loading seasons...",
|
||||
"LOADING_ALL_CHANNELS": "Loading full channel list…",
|
||||
"REFRESH_CHANNEL_LIST": "Refresh channel list",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No channels in this category",
|
||||
"ITEM": "item",
|
||||
"ITEMS": "items",
|
||||
"NO_EPISODES": "No episodes available",
|
||||
"ALL_RADIO": "All radio"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
|
||||
"PLAYBACK_ERROR": "No se pudo obtener la URL de reproducción",
|
||||
"LOADING_SEASONS": "Cargando temporadas…",
|
||||
"LOADING_ALL_CHANNELS": "Cargando la lista completa de canales…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualizar lista de canales",
|
||||
"NO_CHANNELS_IN_CATEGORY": "No hay canales en esta categoría",
|
||||
"ITEM": "elemento",
|
||||
"ITEMS": "elementos",
|
||||
"NO_EPISODES": "No hay episodios disponibles",
|
||||
"ALL_RADIO": "Todas las radios"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
|
||||
"PLAYBACK_ERROR": "Échec de l'obtention de l'URL de lecture",
|
||||
"LOADING_SEASONS": "Chargement des saisons…",
|
||||
"LOADING_ALL_CHANNELS": "Chargement de la liste complète des chaînes…",
|
||||
"REFRESH_CHANNEL_LIST": "Actualiser la liste des chaînes",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Aucune chaîne dans cette catégorie",
|
||||
"ITEM": "élément",
|
||||
"ITEMS": "éléments",
|
||||
"NO_EPISODES": "Aucun épisode disponible",
|
||||
"ALL_RADIO": "Toutes les radios"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
|
||||
"PLAYBACK_ERROR": "Impossibile ottenere l'URL di riproduzione",
|
||||
"LOADING_SEASONS": "Caricamento stagioni...",
|
||||
"LOADING_ALL_CHANNELS": "Caricamento dell'elenco completo dei canali…",
|
||||
"REFRESH_CHANNEL_LIST": "Aggiorna elenco canali",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nessun canale in questa categoria",
|
||||
"ITEM": "elemento",
|
||||
"ITEMS": "elementi",
|
||||
"NO_EPISODES": "Nessun episodio disponibile",
|
||||
"ALL_RADIO": "Tutte le radio"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
|
||||
"PLAYBACK_ERROR": "再生URLの取得に失敗しました",
|
||||
"LOADING_SEASONS": "シーズンを読み込み中...",
|
||||
"LOADING_ALL_CHANNELS": "全チャンネルリストを読み込み中…",
|
||||
"REFRESH_CHANNEL_LIST": "チャンネルリストを更新",
|
||||
"NO_CHANNELS_IN_CATEGORY": "このカテゴリーにチャンネルがありません",
|
||||
"ITEM": "件",
|
||||
"ITEMS": "件",
|
||||
"NO_EPISODES": "利用可能なエピソードはありません",
|
||||
"ALL_RADIO": "すべてのラジオ"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
|
||||
"PLAYBACK_ERROR": "재생 URL을 가져오지 못했습니다",
|
||||
"LOADING_SEASONS": "시즌을 불러오는 중...",
|
||||
"LOADING_ALL_CHANNELS": "전체 채널 목록 불러오는 중…",
|
||||
"REFRESH_CHANNEL_LIST": "채널 목록 새로고침",
|
||||
"NO_CHANNELS_IN_CATEGORY": "이 카테고리에 채널이 없습니다",
|
||||
"ITEM": "개",
|
||||
"ITEMS": "개",
|
||||
"NO_EPISODES": "사용 가능한 에피소드가 없습니다",
|
||||
"ALL_RADIO": "모든 라디오"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
|
||||
"PLAYBACK_ERROR": "Ophalen van afspeel-URL mislukt",
|
||||
"LOADING_SEASONS": "Seizoenen laden...",
|
||||
"LOADING_ALL_CHANNELS": "Volledige zenderlijst wordt geladen…",
|
||||
"REFRESH_CHANNEL_LIST": "Zenderlijst vernieuwen",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Geen zenders in deze categorie",
|
||||
"ITEM": "item",
|
||||
"ITEMS": "items",
|
||||
"NO_EPISODES": "Geen afleveringen beschikbaar",
|
||||
"ALL_RADIO": "Alle radio"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
|
||||
"PLAYBACK_ERROR": "Nie udało się uzyskać URL odtwarzania",
|
||||
"LOADING_SEASONS": "Ładowanie sezonów...",
|
||||
"LOADING_ALL_CHANNELS": "Ładowanie pełnej listy kanałów…",
|
||||
"REFRESH_CHANNEL_LIST": "Odśwież listę kanałów",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Brak kanałów w tej kategorii",
|
||||
"ITEM": "pozycja",
|
||||
"ITEMS": "pozycji",
|
||||
"NO_EPISODES": "Brak dostępnych odcinków",
|
||||
"ALL_RADIO": "Wszystkie radia"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
|
||||
"PLAYBACK_ERROR": "Falha ao obter URL de reprodução",
|
||||
"LOADING_SEASONS": "Carregando temporadas...",
|
||||
"LOADING_ALL_CHANNELS": "A carregar a lista completa de canais…",
|
||||
"REFRESH_CHANNEL_LIST": "Atualizar lista de canais",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Nenhum canal nesta categoria",
|
||||
"ITEM": "item",
|
||||
"ITEMS": "itens",
|
||||
"NO_EPISODES": "Nenhum episódio disponível",
|
||||
"ALL_RADIO": "Todas as rádios"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
|
||||
"PLAYBACK_ERROR": "Не удалось получить URL для воспроизведения",
|
||||
"LOADING_SEASONS": "Загрузка сезонов…",
|
||||
"LOADING_ALL_CHANNELS": "Загрузка полного списка каналов…",
|
||||
"REFRESH_CHANNEL_LIST": "Обновить список каналов",
|
||||
"NO_CHANNELS_IN_CATEGORY": "В этой категории нет каналов",
|
||||
"ITEM": "элемент",
|
||||
"ITEMS": "элементов",
|
||||
"NO_EPISODES": "Нет доступных эпизодов",
|
||||
"ALL_RADIO": "Все радио"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
|
||||
"PLAYBACK_ERROR": "Oynatma URL'si alınamadı",
|
||||
"LOADING_SEASONS": "Sezonlar yükleniyor...",
|
||||
"LOADING_ALL_CHANNELS": "Tüm kanal listesi yükleniyor…",
|
||||
"REFRESH_CHANNEL_LIST": "Kanal listesini yenile",
|
||||
"NO_CHANNELS_IN_CATEGORY": "Bu kategoride kanal yok",
|
||||
"ITEM": "öğe",
|
||||
"ITEMS": "öğe",
|
||||
"NO_EPISODES": "Bölüm mevcut değil",
|
||||
"ALL_RADIO": "Tüm radyolar"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
|
||||
"PLAYBACK_ERROR": "获取播放 URL 失败",
|
||||
"LOADING_SEASONS": "正在加载季…",
|
||||
"LOADING_ALL_CHANNELS": "正在加载完整频道列表…",
|
||||
"REFRESH_CHANNEL_LIST": "刷新频道列表",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分类中没有频道",
|
||||
"ITEM": "项",
|
||||
"ITEMS": "项",
|
||||
"NO_EPISODES": "没有可用的剧集",
|
||||
"ALL_RADIO": "所有广播"
|
||||
},
|
||||
|
||||
@@ -968,6 +968,11 @@
|
||||
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
|
||||
"PLAYBACK_ERROR": "無法取得播放網址",
|
||||
"LOADING_SEASONS": "正在載入季數...",
|
||||
"LOADING_ALL_CHANNELS": "正在載入完整頻道清單…",
|
||||
"REFRESH_CHANNEL_LIST": "重新整理頻道清單",
|
||||
"NO_CHANNELS_IN_CATEGORY": "此分類中沒有頻道",
|
||||
"ITEM": "項",
|
||||
"ITEMS": "項",
|
||||
"NO_EPISODES": "沒有可用的集數",
|
||||
"ALL_RADIO": "所有廣播"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user