@ciphore/radiocli 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +36 -2
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +169 -103
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +26 -6
- package/dist/providers/radio-browser.js +139 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +253 -152
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +251 -47
- package/dist/ui/AppContent.js +9 -10
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +9 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +12 -1
- package/dist/ui/screen-items.js +60 -22
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +93 -21
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +29 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +24 -7
- package/dist/ui/use-command-executor.js +28 -0
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -235
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ProviderCache } from './cache.js';
|
|
3
3
|
import { userAgent } from '../version.js';
|
|
4
|
+
import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
4
5
|
const stationSchema = z.object({
|
|
5
6
|
stationuuid: z.string(),
|
|
6
7
|
name: z.string().default('Untitled station'),
|
|
@@ -47,6 +48,15 @@ const locationSchema = z
|
|
|
47
48
|
const geoAtlasLimit = 100_000;
|
|
48
49
|
const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
|
|
49
50
|
const geoAtlasTimeoutMs = 20_000;
|
|
51
|
+
class ProviderUnavailableError extends Error {
|
|
52
|
+
constructor(message) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = 'ProviderUnavailableError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function isProviderUnavailableError(error) {
|
|
58
|
+
return error instanceof ProviderUnavailableError;
|
|
59
|
+
}
|
|
50
60
|
export class RadioBrowserProvider {
|
|
51
61
|
cache;
|
|
52
62
|
id = 'radio-browser';
|
|
@@ -66,9 +76,11 @@ export class RadioBrowserProvider {
|
|
|
66
76
|
}
|
|
67
77
|
async countries(limit = 240) {
|
|
68
78
|
const rows = await this.request('/json/countries', { hidebroken: 'true' }, { maxAgeMs: 24 * 60 * 60 * 1000 });
|
|
69
|
-
return
|
|
70
|
-
.
|
|
71
|
-
.
|
|
79
|
+
return rows
|
|
80
|
+
.flatMap(row => {
|
|
81
|
+
const parsed = countrySchema.safeParse(row);
|
|
82
|
+
return parsed.success ? [parsed.data] : [];
|
|
83
|
+
})
|
|
72
84
|
.map(row => ({
|
|
73
85
|
name: row.name,
|
|
74
86
|
code: row.iso_3166_1.toUpperCase(),
|
|
@@ -104,68 +116,81 @@ export class RadioBrowserProvider {
|
|
|
104
116
|
return this.popular(options.limit ?? 80);
|
|
105
117
|
}
|
|
106
118
|
const limit = options.limit ?? 60;
|
|
119
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
107
120
|
const baseParams = {
|
|
108
121
|
hidebroken: 'true',
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
// Build a stable merged result set before slicing the requested page;
|
|
123
|
+
// using the merged count as four independent provider offsets skips data.
|
|
124
|
+
limit: String(limit + offset),
|
|
125
|
+
offset: '0',
|
|
111
126
|
order: 'clickcount',
|
|
112
127
|
reverse: 'true',
|
|
113
128
|
...(options.codec ? { codec: options.codec } : {}),
|
|
114
129
|
...(options.language ? { language: options.language } : {}),
|
|
115
130
|
...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
|
|
116
131
|
};
|
|
117
|
-
const
|
|
132
|
+
const primary = await Promise.allSettled([
|
|
118
133
|
this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
119
|
-
this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
|
|
120
|
-
this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
121
|
-
this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
|
|
134
|
+
this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
|
|
122
135
|
]);
|
|
123
|
-
let
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
let attempts = [...primary];
|
|
137
|
+
let rows = fulfilledRows(primary);
|
|
138
|
+
if (rows.length < Math.min(12, limit + offset)) {
|
|
139
|
+
const secondary = await Promise.allSettled([
|
|
140
|
+
this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
141
|
+
this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
|
|
142
|
+
]);
|
|
143
|
+
attempts = [...attempts, ...secondary];
|
|
144
|
+
rows.push(...fulfilledRows(secondary));
|
|
129
145
|
}
|
|
130
146
|
const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
|
|
131
147
|
if (rows.length === 0 && tokens.length > 1) {
|
|
132
|
-
const tokenResults = await Promise.allSettled(tokens.flatMap(token => [
|
|
148
|
+
const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
|
|
133
149
|
this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
134
|
-
this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
|
|
135
|
-
this.request('/json/stations/search', { ...baseParams, country: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
136
|
-
this.request('/json/stations/search', { ...baseParams, language: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
|
|
150
|
+
this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
|
|
137
151
|
]));
|
|
138
|
-
|
|
152
|
+
attempts.push(...tokenResults);
|
|
153
|
+
rows = fulfilledRows(tokenResults);
|
|
154
|
+
}
|
|
155
|
+
if (attempts.every(result => result.status === 'rejected')) {
|
|
156
|
+
const firstError = attempts[0]?.status === 'rejected' ? attempts[0].reason : 'all search requests failed';
|
|
157
|
+
const message = firstError instanceof Error ? firstError.message : String(firstError);
|
|
158
|
+
throw new Error(`${this.label} search failed: ${message}`);
|
|
139
159
|
}
|
|
140
160
|
return filterStations(dedupeStations(this.normalizeStations(rows)), options)
|
|
141
161
|
.sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
|
|
142
|
-
.slice(
|
|
162
|
+
.slice(offset, offset + limit);
|
|
143
163
|
}
|
|
144
164
|
async nearby(location, limit = 80) {
|
|
145
165
|
const stations = await this.geotaggedAtlas();
|
|
146
|
-
return stations
|
|
147
|
-
.map(station => ({
|
|
148
|
-
...station,
|
|
149
|
-
distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
|
|
150
|
-
}))
|
|
151
|
-
.sort(compareNearbyStations)
|
|
152
|
-
.slice(0, limit);
|
|
166
|
+
return nearestStations(stations, location, limit);
|
|
153
167
|
}
|
|
154
168
|
async resolve(station) {
|
|
155
169
|
if (station.provider !== this.id) {
|
|
156
|
-
|
|
170
|
+
const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
|
|
171
|
+
if (!streamUrl) {
|
|
157
172
|
throw new Error(`Station ${station.name} does not expose a stream URL.`);
|
|
158
173
|
}
|
|
159
|
-
return { url:
|
|
174
|
+
return { url: streamUrl, name: station.name };
|
|
160
175
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
try {
|
|
177
|
+
// Click URLs can rotate and the endpoint records directory engagement, so
|
|
178
|
+
// do not reuse a day-old resolution from the general provider cache.
|
|
179
|
+
const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 6000 }));
|
|
180
|
+
const resolvedUrl = result.url ? safeMediaTarget(result.url) : null;
|
|
181
|
+
if (result.ok && resolvedUrl) {
|
|
182
|
+
return { url: resolvedUrl, name: sanitizeTerminalText(result.name) ?? station.name };
|
|
165
183
|
}
|
|
166
|
-
throw new Error(result.message ?? `Radio Browser could not resolve ${station.name}.`);
|
|
167
184
|
}
|
|
168
|
-
|
|
185
|
+
catch {
|
|
186
|
+
// A directory outage should not discard a validated URL already supplied
|
|
187
|
+
// with the station row.
|
|
188
|
+
}
|
|
189
|
+
const fallbackUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
|
|
190
|
+
if (fallbackUrl) {
|
|
191
|
+
return { url: fallbackUrl, name: station.name };
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Radio Browser could not resolve ${station.name}.`);
|
|
169
194
|
}
|
|
170
195
|
// Give back to the directory: an upvote on favorite helps surface good
|
|
171
196
|
// stations for everyone. Radio Browser rate-limits votes per IP, so this is
|
|
@@ -215,9 +240,11 @@ export class RadioBrowserProvider {
|
|
|
215
240
|
}
|
|
216
241
|
}
|
|
217
242
|
normalizeStations(rows) {
|
|
218
|
-
return
|
|
219
|
-
.
|
|
220
|
-
.
|
|
243
|
+
return rows
|
|
244
|
+
.flatMap(row => {
|
|
245
|
+
const parsed = stationSchema.safeParse(row);
|
|
246
|
+
return parsed.success ? [parsed.data] : [];
|
|
247
|
+
})
|
|
221
248
|
.map(row => ({
|
|
222
249
|
id: row.stationuuid,
|
|
223
250
|
provider: this.id,
|
|
@@ -230,9 +257,9 @@ export class RadioBrowserProvider {
|
|
|
230
257
|
tags: splitCsv(row.tags ?? undefined).slice(0, 8),
|
|
231
258
|
codec: cleanText(row.codec ?? undefined),
|
|
232
259
|
bitrate: row.bitrate ?? undefined,
|
|
233
|
-
homepage:
|
|
234
|
-
favicon:
|
|
235
|
-
streamUrl:
|
|
260
|
+
homepage: safeOptionalExternalUrl(row.homepage),
|
|
261
|
+
favicon: safeOptionalExternalUrl(row.favicon),
|
|
262
|
+
streamUrl: safeOptionalMediaTarget(row.url_resolved ?? row.url),
|
|
236
263
|
votes: row.votes ?? undefined,
|
|
237
264
|
clickCount: row.clickcount ?? undefined,
|
|
238
265
|
latitude: row.geo_lat ?? undefined,
|
|
@@ -274,20 +301,32 @@ export class RadioBrowserProvider {
|
|
|
274
301
|
}
|
|
275
302
|
}
|
|
276
303
|
let lastError = null;
|
|
277
|
-
|
|
304
|
+
const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
|
|
305
|
+
const totalTimeoutMs = options.timeoutMs ?? 9000;
|
|
306
|
+
const deadline = Date.now() + totalTimeoutMs;
|
|
307
|
+
for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
|
|
308
|
+
const baseUrl = mirrors[mirrorIndex];
|
|
309
|
+
const remainingMs = deadline - Date.now();
|
|
310
|
+
if (remainingMs <= 0) {
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
278
313
|
const url = new URL(`${baseUrl}${path}`);
|
|
279
314
|
for (const [key, value] of Object.entries(params)) {
|
|
280
315
|
url.searchParams.set(key, value);
|
|
281
316
|
}
|
|
282
317
|
try {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
const value = (await response.json());
|
|
318
|
+
const mirrorsLeft = mirrors.length - mirrorIndex;
|
|
319
|
+
const attemptTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
|
|
320
|
+
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
|
|
288
321
|
this.activeBaseUrl = baseUrl;
|
|
289
322
|
if (options.maxAgeMs) {
|
|
290
|
-
|
|
323
|
+
try {
|
|
324
|
+
this.cache.set(cacheKey, value);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// A read-only or full cache directory must not turn live data into
|
|
328
|
+
// an apparent provider outage.
|
|
329
|
+
}
|
|
291
330
|
}
|
|
292
331
|
return value;
|
|
293
332
|
}
|
|
@@ -299,7 +338,7 @@ export class RadioBrowserProvider {
|
|
|
299
338
|
if (stale) {
|
|
300
339
|
return stale;
|
|
301
340
|
}
|
|
302
|
-
throw lastError ??
|
|
341
|
+
throw new ProviderUnavailableError(lastError?.message ?? `${this.label} request failed.`);
|
|
303
342
|
}
|
|
304
343
|
}
|
|
305
344
|
function defaultRadioBrowserMirrors() {
|
|
@@ -317,17 +356,22 @@ function buildCacheKey(path, params) {
|
|
|
317
356
|
const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
|
318
357
|
return `${path}?${new URLSearchParams(pairs).toString()}`;
|
|
319
358
|
}
|
|
320
|
-
async function
|
|
359
|
+
async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
321
360
|
const controller = new AbortController();
|
|
322
361
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
323
362
|
try {
|
|
324
|
-
|
|
363
|
+
const response = await fetch(url, {
|
|
325
364
|
signal: controller.signal,
|
|
326
365
|
headers: {
|
|
327
366
|
'User-Agent': userAgent(' (+https://radio-browser.info)'),
|
|
328
367
|
Accept: 'application/json'
|
|
329
368
|
}
|
|
330
369
|
});
|
|
370
|
+
if (!response.ok) {
|
|
371
|
+
throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`);
|
|
372
|
+
}
|
|
373
|
+
// Keep the abort timer alive while consuming the body as well as headers.
|
|
374
|
+
return (await response.json());
|
|
331
375
|
}
|
|
332
376
|
finally {
|
|
333
377
|
clearTimeout(timeout);
|
|
@@ -356,8 +400,15 @@ function splitCsv(value) {
|
|
|
356
400
|
.filter((part) => Boolean(part));
|
|
357
401
|
}
|
|
358
402
|
function cleanText(value) {
|
|
359
|
-
|
|
360
|
-
|
|
403
|
+
return sanitizeTerminalText(value);
|
|
404
|
+
}
|
|
405
|
+
function safeOptionalExternalUrl(value) {
|
|
406
|
+
const cleaned = cleanText(value);
|
|
407
|
+
return cleaned ? safeExternalHttpUrl(cleaned) ?? undefined : undefined;
|
|
408
|
+
}
|
|
409
|
+
function safeOptionalMediaTarget(value) {
|
|
410
|
+
const cleaned = cleanText(value);
|
|
411
|
+
return cleaned ? safeMediaTarget(cleaned) ?? undefined : undefined;
|
|
361
412
|
}
|
|
362
413
|
function hasValidCoordinates(station) {
|
|
363
414
|
return (typeof station.latitude === 'number' &&
|
|
@@ -378,6 +429,35 @@ function compareNearbyStations(a, b) {
|
|
|
378
429
|
}
|
|
379
430
|
return a.name.localeCompare(b.name);
|
|
380
431
|
}
|
|
432
|
+
function nearestStations(stations, location, limit) {
|
|
433
|
+
const nearest = [];
|
|
434
|
+
const boundedLimit = Math.max(0, Math.floor(limit));
|
|
435
|
+
if (boundedLimit === 0)
|
|
436
|
+
return nearest;
|
|
437
|
+
for (const station of stations) {
|
|
438
|
+
if (!hasValidCoordinates(station))
|
|
439
|
+
continue;
|
|
440
|
+
const candidate = {
|
|
441
|
+
...station,
|
|
442
|
+
distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
|
|
443
|
+
};
|
|
444
|
+
let low = 0;
|
|
445
|
+
let high = nearest.length;
|
|
446
|
+
while (low < high) {
|
|
447
|
+
const middle = (low + high) >>> 1;
|
|
448
|
+
if (compareNearbyStations(nearest[middle], candidate) <= 0)
|
|
449
|
+
low = middle + 1;
|
|
450
|
+
else
|
|
451
|
+
high = middle;
|
|
452
|
+
}
|
|
453
|
+
if (low < boundedLimit) {
|
|
454
|
+
nearest.splice(low, 0, candidate);
|
|
455
|
+
if (nearest.length > boundedLimit)
|
|
456
|
+
nearest.pop();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return nearest;
|
|
460
|
+
}
|
|
381
461
|
function nearbyQualityScore(station) {
|
|
382
462
|
return ((station.lastCheckedOk ? 100 : 0) +
|
|
383
463
|
Math.log10((station.clickCount ?? 0) + 1) * 12 +
|
|
@@ -395,6 +475,9 @@ function haversineKm(lat1, lon1, lat2, lon2) {
|
|
|
395
475
|
function toRad(value) {
|
|
396
476
|
return (value * Math.PI) / 180;
|
|
397
477
|
}
|
|
478
|
+
function fulfilledRows(results) {
|
|
479
|
+
return results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
480
|
+
}
|
|
398
481
|
function scoreStation(station, query, tokens) {
|
|
399
482
|
const searchable = [
|
|
400
483
|
station.name,
|
|
@@ -417,12 +500,11 @@ function filterStations(stations, options) {
|
|
|
417
500
|
if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
|
|
418
501
|
return false;
|
|
419
502
|
}
|
|
420
|
-
if (options.codec && station.codec
|
|
503
|
+
if (options.codec && (!station.codec || station.codec.toLowerCase() !== options.codec.toLowerCase())) {
|
|
421
504
|
return false;
|
|
422
505
|
}
|
|
423
506
|
if (options.language &&
|
|
424
|
-
station.language
|
|
425
|
-
!station.language.toLowerCase().includes(options.language.toLowerCase())) {
|
|
507
|
+
(!station.language || !station.language.toLowerCase().includes(options.language.toLowerCase()))) {
|
|
426
508
|
return false;
|
|
427
509
|
}
|
|
428
510
|
return true;
|
package/dist/safety.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
// C0/C1 controls, DEL, ANSI CSI/OSC sequences, and bidi overrides can alter a
|
|
3
|
+
// terminal outside the text's visible cells. Preserve ordinary Unicode.
|
|
4
|
+
const terminalEscapePattern = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\)?)/g;
|
|
5
|
+
const unsafeControlPattern = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
|
|
6
|
+
const bidiControlPattern = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
|
|
7
|
+
export function sanitizeTerminalText(value) {
|
|
8
|
+
const cleaned = value
|
|
9
|
+
?.replace(terminalEscapePattern, '')
|
|
10
|
+
.replace(unsafeControlPattern, '')
|
|
11
|
+
.replace(bidiControlPattern, '')
|
|
12
|
+
.replace(/\s+/g, ' ')
|
|
13
|
+
.trim();
|
|
14
|
+
return cleaned || undefined;
|
|
15
|
+
}
|
|
16
|
+
export function safeExternalHttpUrl(value) {
|
|
17
|
+
try {
|
|
18
|
+
const cleaned = value.trim();
|
|
19
|
+
const parsed = new URL(cleaned);
|
|
20
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? cleaned : null;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Accept public HTTP(S) streams and explicit local playlist targets. */
|
|
27
|
+
export function safeMediaTarget(value) {
|
|
28
|
+
const target = value.trim();
|
|
29
|
+
if (!target || target.startsWith('-')) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (isAbsolute(target)) {
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const parsed = new URL(target);
|
|
37
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'file:'
|
|
38
|
+
? target
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|