From 1b6b7db25a779258338bcdea2a4cd6a299453f9a Mon Sep 17 00:00:00 2001 From: chen zhihao <2490284538@qq.com> Date: Mon, 31 Aug 2026 20:45:07 +0800 Subject: [PATCH 1/5] fix: preserve nested playlist paths when formatting --- scripts/commands/playlist/format.ts | 5 ++--- scripts/utils.ts | 10 ++++++++++ .../__data__/input/playlist_paths/nested/nested.m3u | 5 +++++ tests/commands/playlist/format.test.ts | 13 ++++++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/__data__/input/playlist_paths/nested/nested.m3u diff --git a/scripts/commands/playlist/format.ts b/scripts/commands/playlist/format.ts index a7c69ee013..f38c797be4 100644 --- a/scripts/commands/playlist/format.ts +++ b/scripts/commands/playlist/format.ts @@ -4,11 +4,10 @@ import { Stream, Playlist } from '../../models' import { Storage } from '@freearhey/storage-js' import { STREAMS_DIR } from '../../constants' import { PlaylistParser } from '../../core' -import { getStreamInfo } from '../../utils' +import { getStoragePath, getStreamInfo } from '../../utils' import { loadData, data } from '../../api' import cliProgress from 'cli-progress' import { eachLimit } from 'async' -import path from 'node:path' import os from 'node:os' program @@ -42,7 +41,7 @@ async function main() { storage: streamsStorage }) let files = program.args.length ? program.args : await streamsStorage.list('**/*.m3u') - files = files.map((filepath: string) => path.basename(filepath)) + files = files.map((filepath: string) => getStoragePath(filepath, STREAMS_DIR)) let streams = await parser.parse(files) streams = streams.map((stream: Stream) => { stream.setGuides(data.guidesGroupedByStreamId.get(stream.getId())) diff --git a/scripts/utils.ts b/scripts/utils.ts index 6e31eafc9b..d94a8cb89a 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -44,6 +44,16 @@ export function normalizeURL(url: string): string { return decodeURIComponent(normalized).replace(/\s/g, '+').toString() } +export function getStoragePath(filepath: string, rootDir: string): string { + const relative = path.relative(path.resolve(rootDir), path.resolve(filepath)) + + if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`)) { + return relative + } + + return filepath +} + export function truncate(string: string, limit: number = 100) { if (!string) return string if (string.length < limit) return string diff --git a/tests/__data__/input/playlist_paths/nested/nested.m3u b/tests/__data__/input/playlist_paths/nested/nested.m3u new file mode 100644 index 0000000000..9e54864def --- /dev/null +++ b/tests/__data__/input/playlist_paths/nested/nested.m3u @@ -0,0 +1,5 @@ +#EXTM3U +#EXTINF:-1 tvg-id="",Zulu (720p) +https://example.com/zulu.m3u8 +#EXTINF:-1 tvg-id="",Alpha (720p) +https://example.com/alpha.m3u8 diff --git a/tests/commands/playlist/format.test.ts b/tests/commands/playlist/format.test.ts index f169322d84..51bf1e261f 100644 --- a/tests/commands/playlist/format.test.ts +++ b/tests/commands/playlist/format.test.ts @@ -30,7 +30,18 @@ describe('playlist:format', () => { ) }) }) -}) + it('formats playlists in nested directories', () => { + fs.emptyDirSync('tests/__data__/output') + fs.copySync('tests/__data__/input/playlist_paths', '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/nested/nested.m3u') + expect(output.indexOf(',Alpha (720p)')).toBeLessThan(output.indexOf(',Zulu (720p)')) + }) +}) function content(filepath: string) { return fs.readFileSync(pathToFileURL(filepath), { encoding: 'utf8' }) From e7e21e166c92bf3565702b94550b52f0e9fe589c Mon Sep 17 00:00:00 2001 From: chen zhihao <2490284538@qq.com> Date: Mon, 31 Aug 2026 20:48:35 +0800 Subject: [PATCH 2/5] fix: reject formatter paths outside storage --- scripts/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/utils.ts b/scripts/utils.ts index d94a8cb89a..db9055e1d8 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -51,6 +51,10 @@ export function getStoragePath(filepath: string, rootDir: string): string { return relative } + if (path.isAbsolute(filepath) || filepath === '..' || filepath.startsWith(`..${path.sep}`)) { + throw new Error(`Filepath "${filepath}" is outside the storage directory`) + } + return filepath } From de096ff4a8f1777382254d58a89093ce4c667ea3 Mon Sep 17 00:00:00 2001 From: chen zhihao <2490284538@qq.com> Date: Tue, 1 Sep 2026 10:53:47 +0800 Subject: [PATCH 3/5] fix: address nested playlist review feedback --- scripts/utils.ts | 12 +++++------- .../nested/nested.m3u | 0 tests/commands/playlist/format.test.ts | 18 ++++++++---------- 3 files changed, 13 insertions(+), 17 deletions(-) rename tests/__data__/input/{playlist_paths => playlist_format}/nested/nested.m3u (100%) diff --git a/scripts/utils.ts b/scripts/utils.ts index db9055e1d8..40108faeec 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -45,17 +45,15 @@ export function normalizeURL(url: string): string { } export function getStoragePath(filepath: string, rootDir: string): string { - const relative = path.relative(path.resolve(rootDir), path.resolve(filepath)) + const root = path.resolve(rootDir) + const target = path.resolve(path.isAbsolute(filepath) ? filepath : path.join(root, filepath)) + const relative = path.relative(root, target) - if (relative && relative !== '..' && !relative.startsWith(`..${path.sep}`)) { - return relative - } - - if (path.isAbsolute(filepath) || filepath === '..' || filepath.startsWith(`..${path.sep}`)) { + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { throw new Error(`Filepath "${filepath}" is outside the storage directory`) } - return filepath + return relative } export function truncate(string: string, limit: number = 100) { diff --git a/tests/__data__/input/playlist_paths/nested/nested.m3u b/tests/__data__/input/playlist_format/nested/nested.m3u similarity index 100% rename from tests/__data__/input/playlist_paths/nested/nested.m3u rename to tests/__data__/input/playlist_format/nested/nested.m3u diff --git a/tests/commands/playlist/format.test.ts b/tests/commands/playlist/format.test.ts index 51bf1e261f..bf1fc0b420 100644 --- a/tests/commands/playlist/format.test.ts +++ b/tests/commands/playlist/format.test.ts @@ -30,18 +30,16 @@ describe('playlist:format', () => { ) }) }) - it('formats playlists in nested directories', () => { - fs.emptyDirSync('tests/__data__/output') - fs.copySync('tests/__data__/input/playlist_paths', 'tests/__data__/output/streams') - - const cmd = - 'cross-env STREAMS_DIR=tests/__data__/output/streams DATA_DIR=tests/__data__/input/data npm run playlist:format' + it('formats playlists in nested directories', () => { + const cmd = `${ENV_VAR} npm run playlist:format` execSync(cmd, { encoding: 'utf8' }) - const output = content('tests/__data__/output/streams/nested/nested.m3u') - expect(output.indexOf(',Alpha (720p)')).toBeLessThan(output.indexOf(',Zulu (720p)')) - }) -}) + const output = content('tests/__data__/output/streams/nested/nested.m3u') + expect(output).toContain(',Alpha (720p)') + expect(output).toContain(',Zulu (720p)') + expect(output.indexOf(',Alpha (720p)')).toBeLessThan(output.indexOf(',Zulu (720p)')) + }) +}) function content(filepath: string) { return fs.readFileSync(pathToFileURL(filepath), { encoding: 'utf8' }) From 506250533025084deefe0850a611901a74a6ee34 Mon Sep 17 00:00:00 2001 From: chen zhihao <2490284538@qq.com> Date: Tue, 1 Sep 2026 10:58:12 +0800 Subject: [PATCH 4/5] fix: handle formatter paths relative to workspace --- scripts/utils.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/utils.ts b/scripts/utils.ts index 40108faeec..3f2717c9f1 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -46,7 +46,15 @@ export function normalizeURL(url: string): string { export function getStoragePath(filepath: string, rootDir: string): string { const root = path.resolve(rootDir) - const target = path.resolve(path.isAbsolute(filepath) ? filepath : path.join(root, filepath)) + const candidate = path.resolve(filepath) + const candidateRelative = path.relative(root, candidate) + const target = + path.isAbsolute(filepath) || + (candidateRelative !== '..' && + !candidateRelative.startsWith(`..${path.sep}`) && + !path.isAbsolute(candidateRelative)) + ? candidate + : path.resolve(root, filepath) const relative = path.relative(root, target) if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { From f527cfd03a0eb83c610cdd84dfe520024b0cb1c7 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Fri, 4 Sep 2026 17:47:43 +0200 Subject: [PATCH 5/5] fix: harden getStoragePath and keep CRLF endings - Normalize the returned path to forward slashes so it matches what Storage.list() (glob) returns, instead of leaking `\` on Windows. - Treat the storage root itself as "not inside", so passing a directory no longer yields an empty path that Storage.load() would open as a dir. - Extract the containment check into isInsideDirectory() and drop the redundant isAbsolute(filepath) branch (path.resolve already handles it). - Restore the repository's CRLF line endings in the nested fixture. --- scripts/utils.ts | 29 +++++++++++-------- .../input/playlist_format/nested/nested.m3u | 10 +++---- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/scripts/utils.ts b/scripts/utils.ts index 20dc5dd402..4674abaea0 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -53,24 +53,29 @@ export function normalizeURL(url: string): string { } } +function isInsideDirectory(rootDir: string, target: string): boolean { + const relative = path.relative(rootDir, target) + + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ) +} + export function getStoragePath(filepath: string, rootDir: string): string { const root = path.resolve(rootDir) - const candidate = path.resolve(filepath) - const candidateRelative = path.relative(root, candidate) - const target = - path.isAbsolute(filepath) || - (candidateRelative !== '..' && - !candidateRelative.startsWith(`..${path.sep}`) && - !path.isAbsolute(candidateRelative)) - ? candidate - : path.resolve(root, filepath) - const relative = path.relative(root, target) + const fromWorkspace = path.resolve(filepath) + const target = isInsideDirectory(root, fromWorkspace) + ? fromWorkspace + : path.resolve(root, filepath) - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + if (!isInsideDirectory(root, target)) { throw new Error(`Filepath "${filepath}" is outside the storage directory`) } - return relative + return path.relative(root, target).split(path.sep).join('/') } export function truncate(string: string, limit: number = 100) { diff --git a/tests/__data__/input/playlist_format/nested/nested.m3u b/tests/__data__/input/playlist_format/nested/nested.m3u index 9e54864def..34fe6f1cce 100644 --- a/tests/__data__/input/playlist_format/nested/nested.m3u +++ b/tests/__data__/input/playlist_format/nested/nested.m3u @@ -1,5 +1,5 @@ -#EXTM3U -#EXTINF:-1 tvg-id="",Zulu (720p) -https://example.com/zulu.m3u8 -#EXTINF:-1 tvg-id="",Alpha (720p) -https://example.com/alpha.m3u8 +#EXTM3U +#EXTINF:-1 tvg-id="",Zulu (720p) +https://example.com/zulu.m3u8 +#EXTINF:-1 tvg-id="",Alpha (720p) +https://example.com/alpha.m3u8