diff --git a/scripts/utils.ts b/scripts/utils.ts index 891f7ad6e5..029b06bb76 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -46,11 +46,22 @@ export function normalizeURL(url: string): string { return url.replace(/\s/g, '+') } + let decoded: string try { - return decodeURIComponent(normalized).replace(/\s/g, '+') + // decodeURI keeps escapes like %2F, %3F and %23, which would change the URL if decoded + decoded = decodeURI(normalized) } catch { - return normalized.replace(/\s/g, '+') + decoded = normalized } + + // "+" means a space only in the query; in the path it is a plus sign + const queryStart = decoded.indexOf('?') + if (queryStart === -1) return decoded.replace(/\s/g, '%20') + + return ( + decoded.slice(0, queryStart).replace(/\s/g, '%20') + + decoded.slice(queryStart).replace(/\s/g, '+') + ) } function isInsideDirectory(rootDir: string, target: string): boolean { diff --git a/tests/__data__/input/playlist_normalize/reserved_escapes.m3u b/tests/__data__/input/playlist_normalize/reserved_escapes.m3u new file mode 100644 index 0000000000..592ffd05b6 --- /dev/null +++ b/tests/__data__/input/playlist_normalize/reserved_escapes.m3u @@ -0,0 +1,7 @@ +#EXTM3U +#EXTINF:-1 tvg-id="",Example Path Space (720p) +http://example.com/TVP%20Wilno%20HD/index.m3u8 +#EXTINF:-1 tvg-id="",Example Path Slash (720p) +http://example.com/a%2Fb/index.m3u8 +#EXTINF:-1 tvg-id="",Example Query Hash (720p) +http://example.com/index.m3u8?id=1%232 diff --git a/tests/commands/playlist/format.test.ts b/tests/commands/playlist/format.test.ts index feeed80fd6..c3f9b0b825 100644 --- a/tests/commands/playlist/format.test.ts +++ b/tests/commands/playlist/format.test.ts @@ -54,6 +54,20 @@ describe('playlist:format', () => { 'http://example.com/a%ZZ' ) }) + + it('keeps percent escapes whose decoding would change the link', () => { + fs.emptyDirSync('tests/__data__/output') + fs.copySync('tests/__data__/input/playlist_normalize', 'tests/__data__/output/streams') + + const cmd = + 'cross-env STREAMS_DIR=tests/__data__/output/streams DATA_DIR=tests/__data__/input/data npm run playlist:format' + execSync(cmd, { encoding: 'utf8' }) + + const output = content('tests/__data__/output/streams/reserved_escapes.m3u') + expect(output).toContain('http://example.com/TVP%20Wilno%20HD/index.m3u8') + expect(output).toContain('http://example.com/a%2Fb/index.m3u8') + expect(output).toContain('http://example.com/index.m3u8?id=1%232') + }) }) function content(filepath: string) {