@ciphore/radiocli 0.1.9 → 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 +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- 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 +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- 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 +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- 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 +109 -10
- 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 +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
|
@@ -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(),
|
|
@@ -87,11 +99,12 @@ export class RadioBrowserProvider {
|
|
|
87
99
|
}, { maxAgeMs: 15 * 60 * 1000 });
|
|
88
100
|
return this.normalizeStations(rows);
|
|
89
101
|
}
|
|
90
|
-
async byCountry(countryCode, limit = 100) {
|
|
102
|
+
async byCountry(countryCode, limit = 100, offset = 0) {
|
|
91
103
|
const rows = await this.request('/json/stations/search', {
|
|
92
104
|
countrycode: countryCode.toUpperCase(),
|
|
93
105
|
hidebroken: 'true',
|
|
94
106
|
limit: String(limit),
|
|
107
|
+
offset: String(Math.max(0, offset)),
|
|
95
108
|
order: 'clickcount',
|
|
96
109
|
reverse: 'true'
|
|
97
110
|
}, { maxAgeMs: 30 * 60 * 1000 });
|
|
@@ -103,67 +116,81 @@ export class RadioBrowserProvider {
|
|
|
103
116
|
return this.popular(options.limit ?? 80);
|
|
104
117
|
}
|
|
105
118
|
const limit = options.limit ?? 60;
|
|
119
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
106
120
|
const baseParams = {
|
|
107
121
|
hidebroken: 'true',
|
|
108
|
-
|
|
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',
|
|
109
126
|
order: 'clickcount',
|
|
110
127
|
reverse: 'true',
|
|
111
128
|
...(options.codec ? { codec: options.codec } : {}),
|
|
112
129
|
...(options.language ? { language: options.language } : {}),
|
|
113
130
|
...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
|
|
114
131
|
};
|
|
115
|
-
const
|
|
132
|
+
const primary = await Promise.allSettled([
|
|
116
133
|
this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
117
|
-
this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
|
|
118
|
-
this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
119
|
-
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 })
|
|
120
135
|
]);
|
|
121
|
-
let
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
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));
|
|
127
145
|
}
|
|
128
146
|
const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
|
|
129
147
|
if (rows.length === 0 && tokens.length > 1) {
|
|
130
|
-
const tokenResults = await Promise.allSettled(tokens.flatMap(token => [
|
|
148
|
+
const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
|
|
131
149
|
this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
132
|
-
this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
|
|
133
|
-
this.request('/json/stations/search', { ...baseParams, country: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
|
|
134
|
-
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 })
|
|
135
151
|
]));
|
|
136
|
-
|
|
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}`);
|
|
137
159
|
}
|
|
138
160
|
return filterStations(dedupeStations(this.normalizeStations(rows)), options)
|
|
139
161
|
.sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
|
|
140
|
-
.slice(
|
|
162
|
+
.slice(offset, offset + limit);
|
|
141
163
|
}
|
|
142
164
|
async nearby(location, limit = 80) {
|
|
143
165
|
const stations = await this.geotaggedAtlas();
|
|
144
|
-
return stations
|
|
145
|
-
.map(station => ({
|
|
146
|
-
...station,
|
|
147
|
-
distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
|
|
148
|
-
}))
|
|
149
|
-
.sort(compareNearbyStations)
|
|
150
|
-
.slice(0, limit);
|
|
166
|
+
return nearestStations(stations, location, limit);
|
|
151
167
|
}
|
|
152
168
|
async resolve(station) {
|
|
153
169
|
if (station.provider !== this.id) {
|
|
154
|
-
|
|
170
|
+
const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
|
|
171
|
+
if (!streamUrl) {
|
|
155
172
|
throw new Error(`Station ${station.name} does not expose a stream URL.`);
|
|
156
173
|
}
|
|
157
|
-
return { url:
|
|
174
|
+
return { url: streamUrl, name: station.name };
|
|
158
175
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 };
|
|
163
183
|
}
|
|
164
|
-
throw new Error(result.message ?? `Radio Browser could not resolve ${station.name}.`);
|
|
165
184
|
}
|
|
166
|
-
|
|
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}.`);
|
|
167
194
|
}
|
|
168
195
|
// Give back to the directory: an upvote on favorite helps surface good
|
|
169
196
|
// stations for everyone. Radio Browser rate-limits votes per IP, so this is
|
|
@@ -213,9 +240,11 @@ export class RadioBrowserProvider {
|
|
|
213
240
|
}
|
|
214
241
|
}
|
|
215
242
|
normalizeStations(rows) {
|
|
216
|
-
return
|
|
217
|
-
.
|
|
218
|
-
.
|
|
243
|
+
return rows
|
|
244
|
+
.flatMap(row => {
|
|
245
|
+
const parsed = stationSchema.safeParse(row);
|
|
246
|
+
return parsed.success ? [parsed.data] : [];
|
|
247
|
+
})
|
|
219
248
|
.map(row => ({
|
|
220
249
|
id: row.stationuuid,
|
|
221
250
|
provider: this.id,
|
|
@@ -228,9 +257,9 @@ export class RadioBrowserProvider {
|
|
|
228
257
|
tags: splitCsv(row.tags ?? undefined).slice(0, 8),
|
|
229
258
|
codec: cleanText(row.codec ?? undefined),
|
|
230
259
|
bitrate: row.bitrate ?? undefined,
|
|
231
|
-
homepage:
|
|
232
|
-
favicon:
|
|
233
|
-
streamUrl:
|
|
260
|
+
homepage: safeOptionalExternalUrl(row.homepage),
|
|
261
|
+
favicon: safeOptionalExternalUrl(row.favicon),
|
|
262
|
+
streamUrl: safeOptionalMediaTarget(row.url_resolved ?? row.url),
|
|
234
263
|
votes: row.votes ?? undefined,
|
|
235
264
|
clickCount: row.clickcount ?? undefined,
|
|
236
265
|
latitude: row.geo_lat ?? undefined,
|
|
@@ -272,20 +301,32 @@ export class RadioBrowserProvider {
|
|
|
272
301
|
}
|
|
273
302
|
}
|
|
274
303
|
let lastError = null;
|
|
275
|
-
|
|
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
|
+
}
|
|
276
313
|
const url = new URL(`${baseUrl}${path}`);
|
|
277
314
|
for (const [key, value] of Object.entries(params)) {
|
|
278
315
|
url.searchParams.set(key, value);
|
|
279
316
|
}
|
|
280
317
|
try {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
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);
|
|
286
321
|
this.activeBaseUrl = baseUrl;
|
|
287
322
|
if (options.maxAgeMs) {
|
|
288
|
-
|
|
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
|
+
}
|
|
289
330
|
}
|
|
290
331
|
return value;
|
|
291
332
|
}
|
|
@@ -297,7 +338,7 @@ export class RadioBrowserProvider {
|
|
|
297
338
|
if (stale) {
|
|
298
339
|
return stale;
|
|
299
340
|
}
|
|
300
|
-
throw lastError ??
|
|
341
|
+
throw new ProviderUnavailableError(lastError?.message ?? `${this.label} request failed.`);
|
|
301
342
|
}
|
|
302
343
|
}
|
|
303
344
|
function defaultRadioBrowserMirrors() {
|
|
@@ -315,17 +356,22 @@ function buildCacheKey(path, params) {
|
|
|
315
356
|
const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
|
316
357
|
return `${path}?${new URLSearchParams(pairs).toString()}`;
|
|
317
358
|
}
|
|
318
|
-
async function
|
|
359
|
+
async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
319
360
|
const controller = new AbortController();
|
|
320
361
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
321
362
|
try {
|
|
322
|
-
|
|
363
|
+
const response = await fetch(url, {
|
|
323
364
|
signal: controller.signal,
|
|
324
365
|
headers: {
|
|
325
366
|
'User-Agent': userAgent(' (+https://radio-browser.info)'),
|
|
326
367
|
Accept: 'application/json'
|
|
327
368
|
}
|
|
328
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());
|
|
329
375
|
}
|
|
330
376
|
finally {
|
|
331
377
|
clearTimeout(timeout);
|
|
@@ -354,8 +400,15 @@ function splitCsv(value) {
|
|
|
354
400
|
.filter((part) => Boolean(part));
|
|
355
401
|
}
|
|
356
402
|
function cleanText(value) {
|
|
357
|
-
|
|
358
|
-
|
|
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;
|
|
359
412
|
}
|
|
360
413
|
function hasValidCoordinates(station) {
|
|
361
414
|
return (typeof station.latitude === 'number' &&
|
|
@@ -376,6 +429,35 @@ function compareNearbyStations(a, b) {
|
|
|
376
429
|
}
|
|
377
430
|
return a.name.localeCompare(b.name);
|
|
378
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
|
+
}
|
|
379
461
|
function nearbyQualityScore(station) {
|
|
380
462
|
return ((station.lastCheckedOk ? 100 : 0) +
|
|
381
463
|
Math.log10((station.clickCount ?? 0) + 1) * 12 +
|
|
@@ -393,6 +475,9 @@ function haversineKm(lat1, lon1, lat2, lon2) {
|
|
|
393
475
|
function toRad(value) {
|
|
394
476
|
return (value * Math.PI) / 180;
|
|
395
477
|
}
|
|
478
|
+
function fulfilledRows(results) {
|
|
479
|
+
return results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
480
|
+
}
|
|
396
481
|
function scoreStation(station, query, tokens) {
|
|
397
482
|
const searchable = [
|
|
398
483
|
station.name,
|
|
@@ -415,12 +500,11 @@ function filterStations(stations, options) {
|
|
|
415
500
|
if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
|
|
416
501
|
return false;
|
|
417
502
|
}
|
|
418
|
-
if (options.codec && station.codec
|
|
503
|
+
if (options.codec && (!station.codec || station.codec.toLowerCase() !== options.codec.toLowerCase())) {
|
|
419
504
|
return false;
|
|
420
505
|
}
|
|
421
506
|
if (options.language &&
|
|
422
|
-
station.language
|
|
423
|
-
!station.language.toLowerCase().includes(options.language.toLowerCase())) {
|
|
507
|
+
(!station.language || !station.language.toLowerCase().includes(options.language.toLowerCase()))) {
|
|
424
508
|
return false;
|
|
425
509
|
}
|
|
426
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
|
+
}
|