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:
4gray authored and GitHub committed 2026-07-23 23:31:49 +02:00
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 & {
+9 -13
View File
@@ -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();
+2
View File
@@ -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,
},
};
/**
+4 -1
View File
@@ -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(
+174
View File
@@ -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(
+5
View File
@@ -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": "كل الراديو"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Усе радыё"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Όλα τα ραδιόφωνα"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "すべてのラジオ"
},
+5
View File
@@ -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": "모든 라디오"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "Все радио"
},
+5
View File
@@ -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"
},
+5
View File
@@ -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": "所有广播"
},
+5
View File
@@ -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": "所有廣播"
},