mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 07:21:35 -07:00
fix(subsync): decode file:// tracks mpv reports for dropped subtitles
- Sync a subtitle dropped onto mpv without hitting "Protocol file: not supported"; decode file:// external-filename/path back to a real path for target, reference, and video - Stream strip proxy: destroy the connection instead of retrying/502 once a response is handed off, and cap buffered playlist bodies - Bridge installer: cap downloaded bundle size to guard against a lying/missing content-length - Anime browser playback: bump generation on dispose so a stale in-flight playEpisode cleans up its own subtitle cache - Fix a flaky sidecar-process test by binding to an OS-assigned port instead of pre-allocating one
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subsync
|
||||||
|
|
||||||
|
- Subsync no longer fails with `Protocol "file:" not supported` on a subtitle that was dropped onto mpv. mpv reports such a track as a percent-encoded `file://` URL, which subsync read as a stream and tried to fetch over HTTP; the URL is now decoded back to its path, so both the retimed target and an alass reference work. A `file://` video path is treated as local too, which restores the video reference and ffsubsync for a dropped file.
|
||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
||||||
import { allocatePort, startSidecar } from './sidecar-process';
|
import { allocatePort, startSidecar } from './sidecar-process';
|
||||||
import type { BundleBinaries } from './sidecar-bundle';
|
import type { BundleBinaries } from './sidecar-bundle';
|
||||||
@@ -92,7 +93,6 @@ test('an early exit is reported with its code rather than waiting out the deadli
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('onExit reports a death after readiness, including to late subscribers', async () => {
|
test('onExit reports a death after readiness, including to late subscribers', async () => {
|
||||||
const port = await allocatePort();
|
|
||||||
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
|
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
|
||||||
const server = http.createServer((_req, res) => {
|
const server = http.createServer((_req, res) => {
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
@@ -100,7 +100,10 @@ test('onExit reports a death after readiness, including to late subscribers', as
|
|||||||
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve));
|
// Bind first and take the port the OS assigned: allocating one up front and
|
||||||
|
// binding it after leaves a window for another listener to claim it.
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
const child = fakeChild();
|
const child = fakeChild();
|
||||||
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
const spawnImpl = (() => child) as unknown as typeof spawnType;
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ export function startStreamStripProxy(
|
|||||||
attempt: number,
|
attempt: number,
|
||||||
): void {
|
): void {
|
||||||
const mayRetry = req.method === 'GET' && attempt === 0;
|
const mayRetry = req.method === 'GET' && attempt === 0;
|
||||||
|
// Once the response is handed off, its headers (and often part of its body)
|
||||||
|
// are already on the wire: a later upstream error can only be reported by
|
||||||
|
// killing the connection, never by retrying or writing a 502.
|
||||||
|
let handedOff = false;
|
||||||
const retry = (): void => {
|
const retry = (): void => {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
|
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
|
||||||
@@ -172,6 +176,7 @@ export function startStreamStripProxy(
|
|||||||
}
|
}
|
||||||
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
|
||||||
}
|
}
|
||||||
|
handedOff = true;
|
||||||
handleUpstreamResponse(req, res, upstream);
|
handleUpstreamResponse(req, res, upstream);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -180,6 +185,11 @@ export function startStreamStripProxy(
|
|||||||
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
|
||||||
});
|
});
|
||||||
upstreamRequest.on('error', (error) => {
|
upstreamRequest.on('error', (error) => {
|
||||||
|
if (handedOff) {
|
||||||
|
log(`[stream-proxy] upstream failed mid-response: ${String(error)}`);
|
||||||
|
res.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mayRetry) {
|
if (mayRetry) {
|
||||||
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
|
||||||
retry();
|
retry();
|
||||||
@@ -214,7 +224,19 @@ export function startStreamStripProxy(
|
|||||||
|
|
||||||
if (isPlaylist) {
|
if (isPlaylist) {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
upstream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
let buffered = 0;
|
||||||
|
upstream.on('data', (chunk: Buffer) => {
|
||||||
|
buffered += chunk.length;
|
||||||
|
// A playlist is text and small; anything this large is not one, and it
|
||||||
|
// has to be held whole in memory to be rewritten.
|
||||||
|
if (buffered > DECISION_BYTES) {
|
||||||
|
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
|
||||||
|
upstream.destroy();
|
||||||
|
res.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
upstream.on('end', () => {
|
upstream.on('end', () => {
|
||||||
const body = rewritePlaylistOrigins(
|
const body = rewritePlaylistOrigins(
|
||||||
Buffer.concat(chunks).toString('utf8'),
|
Buffer.concat(chunks).toString('utf8'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
codecToExtension,
|
codecToExtension,
|
||||||
fileExists,
|
fileExists,
|
||||||
MpvTrack,
|
MpvTrack,
|
||||||
|
resolveLocalMediaPath,
|
||||||
runCommand,
|
runCommand,
|
||||||
summarizeCommandFailure,
|
summarizeCommandFailure,
|
||||||
} from '../../subsync/utils';
|
} from '../../subsync/utils';
|
||||||
@@ -148,10 +149,12 @@ export async function extractSubtitleTrackToFile(
|
|||||||
input: SubtitleExtractionInput,
|
input: SubtitleExtractionInput,
|
||||||
): Promise<FileExtractionResult> {
|
): Promise<FileExtractionResult> {
|
||||||
if (input.track.external) {
|
if (input.track.external) {
|
||||||
const externalPath = input.track['external-filename'];
|
const externalFilename = input.track['external-filename'];
|
||||||
if (typeof externalPath !== 'string' || externalPath.length === 0) {
|
if (typeof externalFilename !== 'string' || externalFilename.length === 0) {
|
||||||
throw new Error('External subtitle track has no file path');
|
throw new Error('External subtitle track has no file path');
|
||||||
}
|
}
|
||||||
|
// A dropped subtitle arrives as a `file://` URL, which is local, not remote.
|
||||||
|
const externalPath = resolveLocalMediaPath(externalFilename);
|
||||||
if (isRemoteMediaPath(externalPath)) {
|
if (isRemoteMediaPath(externalPath)) {
|
||||||
return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders);
|
return downloadRemoteSubtitleTrack(externalPath, input.httpHeaders);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as fs from 'fs';
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
import { runSubsyncManual } from './subsync';
|
import { runSubsyncManual } from './subsync';
|
||||||
import type { TriggerSubsyncFromConfigDeps } from './subsync';
|
import type { TriggerSubsyncFromConfigDeps } from './subsync';
|
||||||
|
|
||||||
@@ -122,3 +123,97 @@ test('runSubsyncManual syncs stream subtitle tracks served over http', async (t)
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropping a subtitle onto mpv hands it a `file://` URL, and mpv reports that
|
||||||
|
* URL back as the track's `external-filename`. Reading it as a stream made the
|
||||||
|
* run die with `Protocol "file:" not supported. Expected "http:"`.
|
||||||
|
*/
|
||||||
|
test('runSubsyncManual retimes a dropped file:// track against a stream reference', async (t) => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
t.skip('stub shell scripts are not executable on Windows');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await startStubHost();
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-file-url-'));
|
||||||
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
fs.writeFileSync(
|
||||||
|
alassPath,
|
||||||
|
`#!/bin/sh\n: > "${alassLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${alassLogPath}"; done\ncp "$2" "${tmpDir}/target.copy"\nprintf '%s' "retimed" > "$3"\nexit 0\n`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Percent-encoded on purpose: the space and the CJK are what a naive
|
||||||
|
// "strip file://" would leave mangled.
|
||||||
|
const targetPath = path.join(tmpDir, 'ワンピース sdh.srt');
|
||||||
|
const targetBody = '1\n00:00:03,000 --> 00:00:04,000\nドロップ\n';
|
||||||
|
fs.writeFileSync(targetPath, targetBody);
|
||||||
|
const referenceUrl = host.url('/subs/en.srt');
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
|
||||||
|
const deps: Pick<TriggerSubsyncFromConfigDeps, 'getMpvClient' | 'getResolvedConfig'> = {
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return host.url('/stream/video.m3u8');
|
||||||
|
if (name === 'sid') return 2;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
lang: 'en',
|
||||||
|
'external-filename': referenceUrl,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
lang: 'ja',
|
||||||
|
'external-filename': pathToFileURL(targetPath).href,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath: '',
|
||||||
|
ffmpegPath: path.join(tmpDir, 'no-such-ffmpeg'),
|
||||||
|
replace: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true, result.message);
|
||||||
|
|
||||||
|
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
|
||||||
|
// The dropped file went in as-is, decoded, and was retimed in place.
|
||||||
|
assert.equal(alassArgs[1], targetPath);
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'target.copy'), 'utf8'), targetBody);
|
||||||
|
assert.equal(alassArgs[2], targetPath);
|
||||||
|
assert.equal(fs.readFileSync(targetPath, 'utf8'), 'retimed');
|
||||||
|
|
||||||
|
const loadCommand = sentCommands.find((command) => command[0] === 'sub-add');
|
||||||
|
assert.equal(loadCommand?.[1], targetPath);
|
||||||
|
} finally {
|
||||||
|
await host.close();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
formatTrackLabel,
|
formatTrackLabel,
|
||||||
getTrackById,
|
getTrackById,
|
||||||
MpvTrack,
|
MpvTrack,
|
||||||
|
resolveLocalMediaPath,
|
||||||
runCommand,
|
runCommand,
|
||||||
summarizeCommandFailure,
|
summarizeCommandFailure,
|
||||||
SubsyncContext,
|
SubsyncContext,
|
||||||
@@ -136,7 +137,9 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
|||||||
client.requestProperty('track-list'),
|
client.requestProperty('track-list'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
// A dropped file is reported as a `file://` URL; alass, ffsubsync and ffmpeg
|
||||||
|
// all need the plain path, and the stream checks must not read it as remote.
|
||||||
|
const videoPath = typeof videoPathRaw === 'string' ? resolveLocalMediaPath(videoPathRaw) : '';
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
throw new Error('No video is currently loaded');
|
throw new Error('No video is currently loaded');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extractin
|
|||||||
/** Neither call has a default deadline, so a hung network would stall install. */
|
/** Neither call has a default deadline, so a hung network would stall install. */
|
||||||
const RELEASES_TIMEOUT_MS = 30_000;
|
const RELEASES_TIMEOUT_MS = 30_000;
|
||||||
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
||||||
|
/**
|
||||||
|
* Hard ceiling on the buffered bundle. The real asset is a JRE plus a jar, well
|
||||||
|
* under this; the cap only stops a wrong or hostile URL from filling memory.
|
||||||
|
*/
|
||||||
|
const MAX_BUNDLE_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
function oversizedBundle(): Error {
|
||||||
|
return new Error(
|
||||||
|
`The anime bridge bundle exceeded ${Math.round(MAX_BUNDLE_BYTES / (1024 * 1024))} MB; ` +
|
||||||
|
'the download was stopped.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstallProgress {
|
export interface InstallProgress {
|
||||||
stage: InstallStage;
|
stage: InstallStage;
|
||||||
@@ -78,7 +90,11 @@ async function downloadWithProgress(
|
|||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const declared = Number(response.headers.get('content-length') ?? '0');
|
const declared = Number(response.headers.get('content-length') ?? '0');
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) return new Uint8Array(await response.arrayBuffer());
|
if (!reader) {
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (buffer.byteLength > MAX_BUNDLE_BYTES) throw oversizedBundle();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
let received = 0;
|
let received = 0;
|
||||||
@@ -86,6 +102,12 @@ async function downloadWithProgress(
|
|||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
if (value) {
|
if (value) {
|
||||||
|
// Checked against the bytes actually read, not content-length: a lying
|
||||||
|
// (or absent) header must not let the bundle allocate without bound.
|
||||||
|
if (received + value.length > MAX_BUNDLE_BYTES) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
throw oversizedBundle();
|
||||||
|
}
|
||||||
chunks.push(value);
|
chunks.push(value);
|
||||||
received += value.length;
|
received += value.length;
|
||||||
if (declared > 0) onProgress?.(Math.min(1, received / declared));
|
if (declared > 0) onProgress?.(Math.min(1, received / declared));
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export function createAnimeBrowserPlayback(options: AnimeBrowserPlaybackOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function dispose(): Promise<void> {
|
async function dispose(): Promise<void> {
|
||||||
|
// Bumping the generation makes any in-flight playEpisode stale, so a cache
|
||||||
|
// it is still writing gets removed by that call instead of outliving us.
|
||||||
|
playbackGeneration += 1;
|
||||||
const cacheDir = subtitleCacheDir;
|
const cacheDir = subtitleCacheDir;
|
||||||
subtitleCacheDir = null;
|
subtitleCacheDir = null;
|
||||||
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
|
await removeSubtitleCache(cacheDir, deps.subtitleCacheIo);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { codecToExtension, getSubsyncConfig } from './utils';
|
import { codecToExtension, getSubsyncConfig, resolveLocalMediaPath } from './utils';
|
||||||
|
|
||||||
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
|
||||||
assert.equal(codecToExtension('subrip'), 'srt');
|
assert.equal(codecToExtension('subrip'), 'srt');
|
||||||
@@ -22,3 +22,21 @@ test('getSubsyncConfig respects explicit replace value', () => {
|
|||||||
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
|
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
|
||||||
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
|
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveLocalMediaPath decodes file URLs mpv reports for dropped files', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLocalMediaPath('file:///home/user/subs/%E3%83%AF%E3%83%B3%20sdh.srt'),
|
||||||
|
'/home/user/subs/ワン sdh.srt',
|
||||||
|
);
|
||||||
|
assert.equal(resolveLocalMediaPath('FILE:///tmp/ref.srt'), '/tmp/ref.srt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLocalMediaPath leaves plain paths and stream URLs alone', () => {
|
||||||
|
assert.equal(resolveLocalMediaPath('/tmp/ref.srt'), '/tmp/ref.srt');
|
||||||
|
assert.equal(
|
||||||
|
resolveLocalMediaPath('https://jellyfin.example/subs/eng.srt'),
|
||||||
|
'https://jellyfin.example/subs/eng.srt',
|
||||||
|
);
|
||||||
|
// A UNC host has no local path; the caller's own error is the useful one.
|
||||||
|
assert.equal(resolveLocalMediaPath('file://host/share/ref.srt'), 'file://host/share/ref.srt');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as childProcess from 'child_process';
|
import * as childProcess from 'child_process';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import { DEFAULT_CONFIG } from '../config';
|
import { DEFAULT_CONFIG } from '../config';
|
||||||
import { SubsyncConfig } from '../types';
|
import { SubsyncConfig } from '../types';
|
||||||
|
|
||||||
@@ -119,6 +120,24 @@ export function summarizeCommandFailure(command: string, result: CommandResult):
|
|||||||
return `command failed (${command}) ${parts.join(' | ')}`;
|
return `command failed (${command}) ${parts.join(' | ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a `file://` URL back into a plain path.
|
||||||
|
*
|
||||||
|
* mpv echoes back whatever it was given, and a drag-and-drop (or a `file://`
|
||||||
|
* argument) hands it a percent-encoded URL. Everything downstream wants a real
|
||||||
|
* path: `fs` cannot stat the URL, alass and ffmpeg cannot open it, and the
|
||||||
|
* remote-track check reads it as a stream and tries to fetch a local file over
|
||||||
|
* HTTP — which fails with `Protocol "file:" not supported`.
|
||||||
|
*/
|
||||||
|
export function resolveLocalMediaPath(value: string): string {
|
||||||
|
if (!/^file:\/\//i.test(value)) return value;
|
||||||
|
try {
|
||||||
|
return fileURLToPath(new URL(value));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function fileExists(pathOrEmpty: string): boolean {
|
export function fileExists(pathOrEmpty: string): boolean {
|
||||||
if (!pathOrEmpty) return false;
|
if (!pathOrEmpty) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user