Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ jobs:
librsvg2-dev \
patchelf \
gstreamer1.0-plugins-base \
gstreamer1.0-plugins-good
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
gstreamer1.0-libav \
gstreamer1.0-gl
- name: Install frontend dependencies
run: npm ci
Expand Down
34 changes: 33 additions & 1 deletion api/youtube/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,28 @@ export default async function handler(request) {
}
const url = new URL(request.url);
const channel = url.searchParams.get('channel');
const videoIdParam = url.searchParams.get('videoId');

// Video ID lookup: resolve author name via oembed
if (videoIdParam && /^[A-Za-z0-9_-]{11}$/.test(videoIdParam)) {
try {
const oembedRes = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoIdParam}&format=json`,
{ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } },
);
if (oembedRes.ok) {
const data = await oembedRes.json();
return new Response(JSON.stringify({ channelName: data.author_name || null, title: data.title || null, videoId: videoIdParam }), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600, s-maxage=3600' },
});
}
} catch {}
return new Response(JSON.stringify({ channelName: null, title: null, videoId: videoIdParam }), {
status: 200,
headers: { ...cors, 'Content-Type': 'application/json' },
});
}

if (!channel) {
return new Response(JSON.stringify({ error: 'Missing channel parameter' }), {
Expand Down Expand Up @@ -47,6 +69,16 @@ export default async function handler(request) {
// Channel exists if the page contains canonical channel metadata
const channelExists = html.includes('"channelId"') || html.includes('og:url');

// Extract channel name from page metadata (prefer channel name over video title)
let channelName = null;
const ownerMatch = html.match(/"ownerChannelName"\s*:\s*"([^"]+)"/);
if (ownerMatch) {
channelName = ownerMatch[1];
} else {
const authorMatch = html.match(/"author"\s*:\s*"([^"]+)"/);
if (authorMatch) channelName = authorMatch[1];
}

// Scope both fields to the same videoDetails block so we don't
// combine a videoId from one object with isLive from another.
let videoId = null;
Expand All @@ -60,7 +92,7 @@ export default async function handler(request) {
}
}

return new Response(JSON.stringify({ videoId, isLive: videoId !== null, channelExists }), {
return new Response(JSON.stringify({ videoId, isLive: videoId !== null, channelExists, channelName }), {
status: 200,
headers: {
'Content-Type': 'application/json',
Expand Down
13 changes: 11 additions & 2 deletions src/components/LiveNewsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'bloomberg', name: 'Bloomberg', handle: '@Bloomberg', fallbackVideoId: 'iEpJwprxDdk' },
{ id: 'yahoo', name: 'Yahoo Finance', handle: '@YahooFinance', fallbackVideoId: 'KQp-e_XQnDE' },
{ id: 'cnbc', name: 'CNBC', handle: '@CNBC', fallbackVideoId: '9NyxcX3rhQs' },
{ id: 'nasa', name: 'NASA TV', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true },
{ id: 'nasa', name: 'Sen Space Live', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true },
];

// Optional channels users can add from the "Available Channels" tab UI
Expand All @@ -85,6 +85,8 @@ export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'cbs-news', name: 'CBS News', handle: '@CBSNews' },
{ id: 'nbc-news', name: 'NBC News', handle: '@NBCNews' },
// Europe
{ id: 'bbc-news', name: 'BBC News', handle: '@BBCNews' },
{ id: 'france24-en', name: 'France 24 English', handle: '@FRANCE24English' },
{ id: 'welt', name: 'WELT', handle: '@WELTNachrichtensender' },
{ id: 'rtve', name: 'RTVE 24H', handle: '@RTVENoticias', fallbackVideoId: '7_srED6k0bE' },
{ id: 'trt-haber', name: 'TRT Haber', handle: '@trthaber' },
Expand All @@ -111,6 +113,12 @@ export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [
{ id: 'vtc-now', name: 'VTC NOW', handle: '@VTCNOW' },
{ id: 'cna-asia', name: 'CNA (NewsAsia)', handle: '@channelnewsasia' },
{ id: 'nhk-world', name: 'NHK World Japan', handle: '@NHKWORLDJAPAN' },
// Middle East
{ id: 'al-hadath', name: 'Al Hadath', handle: '@AlHadath', fallbackVideoId: 'xWXpl7azI8k', useFallbackOnly: true },
{ id: 'sky-news-arabia', name: 'Sky News Arabia', handle: '@skynewsarabia' },
{ id: 'trt-world', name: 'TRT World', handle: '@taborrtworld' },
{ id: 'iran-intl', name: 'Iran International', handle: '@IranIntl' },
{ id: 'cgtn-arabic', name: 'CGTN Arabic', handle: '@CGTNArabic' },
// Africa
{ id: 'africanews', name: 'Africanews', handle: '@africanews' },
{ id: 'channels-tv', name: 'Channels TV', handle: '@channelstv' },
Expand All @@ -121,9 +129,10 @@ export const OPTIONAL_LIVE_CHANNELS: LiveChannel[] = [

export const OPTIONAL_CHANNEL_REGIONS: { key: string; labelKey: string; channelIds: string[] }[] = [
{ key: 'na', labelKey: 'components.liveNews.regionNorthAmerica', channelIds: ['livenow-fox', 'fox-news', 'newsmax', 'abc-news', 'cbs-news', 'nbc-news'] },
{ key: 'eu', labelKey: 'components.liveNews.regionEurope', channelIds: ['welt', 'rtve', 'trt-haber', 'ntv-turkey', 'cnn-turk', 'tv-rain'] },
{ key: 'eu', labelKey: 'components.liveNews.regionEurope', channelIds: ['bbc-news', 'france24-en', 'welt', 'rtve', 'trt-haber', 'ntv-turkey', 'cnn-turk', 'tv-rain'] },
{ key: 'latam', labelKey: 'components.liveNews.regionLatinAmerica', channelIds: ['cnn-brasil', 'jovem-pan', 'record-news', 'band-jornalismo', 'tn-argentina', 'c5n', 'milenio', 'noticias-caracol', 'ntn24', 't13'] },
{ key: 'asia', labelKey: 'components.liveNews.regionAsia', channelIds: ['tbs-news', 'ann-news', 'ntv-news', 'cti-news', 'wion', 'vtc-now', 'cna-asia', 'nhk-world'] },
{ key: 'me', labelKey: 'components.liveNews.regionMiddleEast', channelIds: ['al-hadath', 'sky-news-arabia', 'trt-world', 'iran-intl', 'cgtn-arabic'] },
{ key: 'africa', labelKey: 'components.liveNews.regionAfrica', channelIds: ['africanews', 'channels-tv', 'ktn-news', 'enca', 'sabc-news'] },
];

Expand Down
88 changes: 82 additions & 6 deletions src/live-channels-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,35 @@ function customChannelIdFromHandle(handle: string): string {
return 'custom-' + normalized;
}

/** Parse YouTube URL into a handle or video ID. Returns null if not a YouTube URL. */
function parseYouTubeInput(raw: string): { handle: string } | { videoId: string } | null {
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (!url.hostname.match(/^(www\.)?(youtube\.com|youtu\.be)$/)) return null;

// youtu.be/VIDEO_ID
if (url.hostname.includes('youtu.be')) {
const vid = url.pathname.slice(1);
if (/^[A-Za-z0-9_-]{11}$/.test(vid)) return { videoId: vid };
return null;
}
// youtube.com/watch?v=VIDEO_ID
const v = url.searchParams.get('v');
if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return { videoId: v };
// youtube.com/@Handle
const handleMatch = url.pathname.match(/^\/@([\w.-]{3,30})$/);
if (handleMatch) return { handle: `@${handleMatch[1]}` };
// youtube.com/c/ChannelName or /channel/ID
const channelMatch = url.pathname.match(/^\/(c|channel)\/([\w.-]+)$/);
if (channelMatch) return { handle: `@${channelMatch[2]}` };

return null;
}

// Persist active region tab across re-renders
let activeRegionTab = OPTIONAL_CHANNEL_REGIONS[0]?.key ?? 'na';

Expand Down Expand Up @@ -341,8 +370,8 @@ export function initLiveChannelsWindow(containerEl?: HTMLElement): void {
<span class="live-news-manage-add-title">${escapeHtml(t('components.liveNews.customChannel') ?? 'Custom channel')}</span>
<div class="live-news-manage-add">
<div class="live-news-manage-add-field">
<label class="live-news-manage-add-label" for="liveChannelsHandle">${escapeHtml(t('components.liveNews.youtubeHandle') ?? 'YouTube handle (e.g. @Channel)')}</label>
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel" />
<label class="live-news-manage-add-label" for="liveChannelsHandle">${escapeHtml(t('components.liveNews.youtubeHandleOrUrl') ?? 'YouTube handle or URL')}</label>
<input type="text" class="live-news-manage-handle" id="liveChannelsHandle" placeholder="@Channel or youtube.com/watch?v=..." />
</div>
<div class="live-news-manage-add-field">
<label class="live-news-manage-add-label" for="liveChannelsName">${escapeHtml(t('components.liveNews.displayName') ?? 'Display name (optional)')}</label>
Expand Down Expand Up @@ -379,7 +408,53 @@ export function initLiveChannelsWindow(containerEl?: HTMLElement): void {
const nameInput = document.getElementById('liveChannelsName') as HTMLInputElement | null;
const raw = handleInput?.value?.trim();
if (!raw) return;
const handle = raw.startsWith('@') ? raw : `@${raw}`;
if (handleInput) handleInput.classList.remove('invalid');

// Try parsing as a YouTube URL first
const parsed = parseYouTubeInput(raw);

// Direct video URL (watch?v= or youtu.be/)
if (parsed && 'videoId' in parsed) {
const videoId = parsed.videoId;
const id = `custom-vid-${videoId}`;
if (channels.some((c) => c.id === id)) return;

if (addBtn) {
addBtn.disabled = true;
addBtn.textContent = t('components.liveNews.verifying') ?? 'Verifying…';
}

// Try to resolve video/channel title via our proxy (YouTube oembed has no CORS)
let resolvedName = nameInput?.value?.trim() || '';
if (!resolvedName) {
try {
const baseUrl = isDesktopRuntime() ? getRemoteApiBaseUrl() : '';
const res = await fetch(`${baseUrl}/api/youtube/live?videoId=${encodeURIComponent(videoId)}`);
if (res.ok) {
const data = await res.json();
resolvedName = data.channelName || data.title || '';
}
} catch { /* use fallback */ }
}
if (!resolvedName) resolvedName = `Video ${videoId}`;

if (addBtn) {
addBtn.disabled = false;
addBtn.textContent = t('components.liveNews.addChannel') ?? 'Add channel';
}

channels.push({ id, name: resolvedName, handle: `@video`, fallbackVideoId: videoId, useFallbackOnly: true });
saveChannelsToStorage(channels);
renderList(listEl);
if (handleInput) handleInput.value = '';
if (nameInput) nameInput.value = '';
return;
}

// Extract handle from URL, or treat raw input as handle
const handle = parsed && 'handle' in parsed
? parsed.handle
: raw.startsWith('@') ? raw : `@${raw}`;

// Validate YouTube handle format: @<3-30 alphanumeric/dot/hyphen/underscore chars>
if (!/^@[\w.-]{3,30}$/i.test(handle)) {
Expand All @@ -393,13 +468,13 @@ export function initLiveChannelsWindow(containerEl?: HTMLElement): void {
const id = customChannelIdFromHandle(handle);
if (channels.some((c) => c.id === id)) return;

// Validate channel exists on YouTube
// Validate channel exists on YouTube + resolve name
if (addBtn) {
addBtn.disabled = true;
addBtn.textContent = t('components.liveNews.verifying') ?? 'Verifying…';
}
if (handleInput) handleInput.classList.remove('invalid');

let resolvedName = '';
try {
const baseUrl = isDesktopRuntime() ? getRemoteApiBaseUrl() : '';
const res = await fetch(`${baseUrl}/api/youtube/live?channel=${encodeURIComponent(handle)}`);
Expand All @@ -412,6 +487,7 @@ export function initLiveChannelsWindow(containerEl?: HTMLElement): void {
}
return;
}
resolvedName = data.channelName || '';
}
// Non-OK status (429, 5xx) or ambiguous response — allow adding anyway
} catch (e) {
Expand All @@ -424,7 +500,7 @@ export function initLiveChannelsWindow(containerEl?: HTMLElement): void {
}
}

const name = nameInput?.value?.trim() || handle;
const name = nameInput?.value?.trim() || resolvedName || handle;
channels.push({ id, name, handle });
saveChannelsToStorage(channels);
renderList(listEl);
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "إضافة قناة",
"remove": "إزالة",
"youtubeHandle": "معرّف YouTube (مثال: @Channel)",
"youtubeHandleOrUrl": "رابط أو معرّف يوتيوب",
"displayName": "اسم العرض (اختياري)",
"openPanelSettings": "إعدادات عرض اللوحة",
"channelSettings": "إعدادات القناة",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "أوروبا",
"regionLatinAmerica": "أمريكا اللاتينية",
"regionAsia": "آسيا",
"regionMiddleEast": "الشرق الأوسط",
"regionAfrica": "أفريقيا"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "Kanal hinzufügen",
"remove": "Entfernen",
"youtubeHandle": "YouTube-Handle (z. B. @Channel)",
"youtubeHandleOrUrl": "YouTube-Handle oder URL",
"displayName": "Anzeigename (optional)",
"openPanelSettings": "Panelanzeige-Einstellungen",
"channelSettings": "Kanaleinstellungen",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "Europa",
"regionLatinAmerica": "Lateinamerika",
"regionAsia": "Asien",
"regionMiddleEast": "Naher Osten",
"regionAfrica": "Afrika"
}
},
Expand Down
1 change: 1 addition & 0 deletions src/locales/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,7 @@
"regionEurope": "Ευρώπη",
"regionLatinAmerica": "Λατινική Αμερική",
"regionAsia": "Ασία",
"regionMiddleEast": "Μέση Ανατολή",
"regionAfrica": "Αφρική"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,7 @@
"addChannel": "Add channel",
"remove": "Remove",
"youtubeHandle": "YouTube handle (e.g. @Channel)",
"youtubeHandleOrUrl": "YouTube handle or URL",
"displayName": "Display name (optional)",
"openPanelSettings": "Panel display settings",
"channelSettings": "Channel Settings",
Expand All @@ -1374,6 +1375,7 @@
"regionEurope": "Europe",
"regionLatinAmerica": "Latin America",
"regionAsia": "Asia",
"regionMiddleEast": "Middle East",
"regionAfrica": "Africa",
"invalidHandle": "Enter a valid YouTube handle (e.g. @ChannelName)",
"channelNotFound": "YouTube channel not found",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "Añadir canal",
"remove": "Eliminar",
"youtubeHandle": "Handle de YouTube (ej. @Channel)",
"youtubeHandleOrUrl": "Identificador o URL de YouTube",
"displayName": "Nombre para mostrar (opcional)",
"openPanelSettings": "Configuración de visualización del panel",
"channelSettings": "Configuración del canal",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "Europa",
"regionLatinAmerica": "Latinoamérica",
"regionAsia": "Asia",
"regionMiddleEast": "Oriente Medio",
"regionAfrica": "África"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "Ajouter une chaîne",
"remove": "Supprimer",
"youtubeHandle": "Handle YouTube (ex. @Channel)",
"youtubeHandleOrUrl": "Identifiant ou URL YouTube",
"displayName": "Nom d'affichage (optionnel)",
"openPanelSettings": "Paramètres d'affichage du panneau",
"channelSettings": "Paramètres de la chaîne",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "Europe",
"regionLatinAmerica": "Amérique latine",
"regionAsia": "Asie",
"regionMiddleEast": "Moyen-Orient",
"regionAfrica": "Afrique"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "Aggiungi canale",
"remove": "Rimuovi",
"youtubeHandle": "Handle YouTube (es. @Channel)",
"youtubeHandleOrUrl": "Handle o URL di YouTube",
"displayName": "Nome visualizzato (opzionale)",
"openPanelSettings": "Impostazioni visualizzazione pannello",
"channelSettings": "Impostazioni canale",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "Europa",
"regionLatinAmerica": "America Latina",
"regionAsia": "Asia",
"regionMiddleEast": "Medio Oriente",
"regionAfrica": "Africa"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "チャンネルを追加",
"remove": "削除",
"youtubeHandle": "YouTube ハンドル(例: @Channel)",
"youtubeHandleOrUrl": "YouTubeハンドルまたはURL",
"displayName": "表示名(任意)",
"openPanelSettings": "表示パネル設定",
"channelSettings": "チャンネル設定",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "ヨーロッパ",
"regionLatinAmerica": "中南米",
"regionAsia": "アジア",
"regionMiddleEast": "中東",
"regionAfrica": "アフリカ"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,7 @@
"addChannel": "Kanaal toevoegen",
"remove": "Verwijderen",
"youtubeHandle": "YouTube-handle (bijv. @Channel)",
"youtubeHandleOrUrl": "YouTube-handle of URL",
"displayName": "Weergavenaam (optioneel)",
"openPanelSettings": "Paneelweergave-instellingen",
"channelSettings": "Kanaalinstellingen",
Expand All @@ -1141,6 +1142,7 @@
"regionEurope": "Europa",
"regionLatinAmerica": "Latijns-Amerika",
"regionAsia": "Azië",
"regionMiddleEast": "Midden-Oosten",
"regionAfrica": "Afrika"
}
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@
"addChannel": "Dodaj kanał",
"remove": "Usuń",
"youtubeHandle": "Handle YouTube (np. @Channel)",
"youtubeHandleOrUrl": "Uchwyt lub URL YouTube",
"displayName": "Nazwa wyświetlana (opcjonalnie)",
"openPanelSettings": "Ustawienia wyświetlania panelu",
"channelSettings": "Ustawienia kanału",
Expand All @@ -1282,6 +1283,7 @@
"regionEurope": "Europa",
"regionLatinAmerica": "Ameryka Łacińska",
"regionAsia": "Azja",
"regionMiddleEast": "Bliski Wschód",
"regionAfrica": "Afryka"
}
},
Expand Down
Loading