adds local play
This commit is contained in:
260
server/index.js
260
server/index.js
@@ -36,6 +36,7 @@ const RECENT_URLS_PATH = process.env.RECENT_URLS_PATH ?? path.join(__dirname, '.
|
||||
const RECENT_URL_LIMIT = clampInteger(process.env.RECENT_URL_LIMIT, 12, 1, 50);
|
||||
const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', 'data', 'favorites.json');
|
||||
const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200);
|
||||
const LOCAL_VIDEOS_ROOT = parseLocalVideosRoot(process.env.LOCAL_VIDEOS);
|
||||
const SESSION_TTL_MS = 60 * 60 * 1000;
|
||||
const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000;
|
||||
const CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS = 0.25;
|
||||
@@ -79,6 +80,7 @@ let recentWrite = Promise.resolve();
|
||||
let favorites = [];
|
||||
let favoritesWrite = Promise.resolve();
|
||||
let ffmpegSupportsReconnectMaxRetries = false;
|
||||
let localVideosRealRootPromise = null;
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '256kb' }));
|
||||
@@ -129,6 +131,29 @@ app.get('/api/favorites', (_request, response) => {
|
||||
response.json({ favorites: formatFavoritesPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/local-videos', async (_request, response) => {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
response.json({ enabled: false, videos: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
response.json({
|
||||
enabled: true,
|
||||
rootName: formatLocalVideosRootName(),
|
||||
videos: await listLocalVideos(),
|
||||
});
|
||||
} catch (error) {
|
||||
logWarn(`local videos listing failed error=${oneLine(error.message)}`);
|
||||
response.json({
|
||||
enabled: true,
|
||||
rootName: formatLocalVideosRootName(),
|
||||
videos: [],
|
||||
error: 'Failed to read local videos.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/favorites', async (request, response) => {
|
||||
let nextFavorites;
|
||||
|
||||
@@ -154,18 +179,20 @@ app.put('/api/favorites', async (request, response) => {
|
||||
});
|
||||
|
||||
app.post('/api/session', async (request, response) => {
|
||||
let url;
|
||||
let requestedSource;
|
||||
let source;
|
||||
|
||||
try {
|
||||
url = parseStreamUrl(request.body?.url);
|
||||
requestedSource = parseSessionSource(request.body);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
source = await resolvePlaybackSource(url);
|
||||
source = requestedSource.type === 'local'
|
||||
? await resolveLocalPlaybackSource(requestedSource.path)
|
||||
: await resolvePlaybackSource(requestedSource.url);
|
||||
} catch (error) {
|
||||
response.status(400).json({ error: error.message });
|
||||
return;
|
||||
@@ -175,14 +202,14 @@ app.post('/api/session', async (request, response) => {
|
||||
const id = randomUUID();
|
||||
const shouldProbeMetadata = METADATA_PROBE_ENABLED || canBestEffortResumeWithoutDuration({
|
||||
sourceKind: source.kind,
|
||||
originalUrl: url,
|
||||
originalUrl: source.originalUrl,
|
||||
url: source.url,
|
||||
});
|
||||
const metadataStatus = shouldProbeMetadata ? 'pending' : 'disabled';
|
||||
const session = {
|
||||
id,
|
||||
url: source.url,
|
||||
originalUrl: url,
|
||||
originalUrl: source.originalUrl,
|
||||
sourceKind: source.kind,
|
||||
sourceHeaders: source.headers,
|
||||
options,
|
||||
@@ -204,10 +231,12 @@ app.post('/api/session', async (request, response) => {
|
||||
startSessionMetadataProbe(session);
|
||||
}
|
||||
|
||||
try {
|
||||
await addRecentUrl(url);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to store recent URL: ${error.message}`);
|
||||
if (requestedSource.type === 'url') {
|
||||
try {
|
||||
await addRecentUrl(requestedSource.url);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to store recent URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
response.status(201).json(formatSessionPayload(sessions.get(id)));
|
||||
@@ -438,6 +467,20 @@ server.listen(PORT, () => {
|
||||
console.log(`Frame stream app listening at http://localhost:${PORT} mode=${PLAYBACK_CONNECTION_MODE}`);
|
||||
});
|
||||
|
||||
function parseSessionSource(body) {
|
||||
if (typeof body?.localPath === 'string' && body.localPath.trim()) {
|
||||
return {
|
||||
type: 'local',
|
||||
path: parseLocalVideoPath(body.localPath),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
url: parseStreamUrl(body?.url),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStreamUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('A stream URL is required.');
|
||||
@@ -456,6 +499,187 @@ function parseStreamUrl(value) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function parseLocalVideosRoot(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.resolve(value.trim());
|
||||
}
|
||||
|
||||
function parseLocalVideoPath(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('A local file is required.');
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed.length > 4096) {
|
||||
throw new Error('The local file path is too long.');
|
||||
}
|
||||
|
||||
if (trimmed.includes('\0')) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
if (path.isAbsolute(trimmed)) {
|
||||
throw new Error('Local file paths must be relative.');
|
||||
}
|
||||
|
||||
const segments = trimmed.split(/[\\/]+/);
|
||||
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
return path.join(...segments);
|
||||
}
|
||||
|
||||
async function resolveLocalPlaybackSource(relativePath) {
|
||||
const { absolutePath, displayPath } = await resolveLocalVideoPath(relativePath);
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
url: absolutePath,
|
||||
originalUrl: displayPath,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLocalVideoPath(relativePath) {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
throw new Error('Local video playback is not enabled.');
|
||||
}
|
||||
|
||||
const parsedPath = parseLocalVideoPath(relativePath);
|
||||
const rootRealPath = await getLocalVideosRealRoot();
|
||||
const candidatePath = path.resolve(LOCAL_VIDEOS_ROOT, parsedPath);
|
||||
|
||||
if (!isPathInside(candidatePath, LOCAL_VIDEOS_ROOT)) {
|
||||
throw new Error('The local file path is invalid.');
|
||||
}
|
||||
|
||||
let realPath;
|
||||
let stats;
|
||||
|
||||
try {
|
||||
realPath = await fs.realpath(candidatePath);
|
||||
stats = await fs.stat(realPath);
|
||||
} catch {
|
||||
throw new Error('Local file was not found.');
|
||||
}
|
||||
|
||||
if (!isPathInside(realPath, rootRealPath)) {
|
||||
throw new Error('The local file path is outside the configured library.');
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('Local selection is not a file.');
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: realPath,
|
||||
displayPath: formatLocalVideoPath(parsedPath),
|
||||
};
|
||||
}
|
||||
|
||||
async function listLocalVideos() {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rootRealPath = await getLocalVideosRealRoot();
|
||||
const videos = [];
|
||||
|
||||
await walkLocalVideosDirectory(LOCAL_VIDEOS_ROOT, '', rootRealPath, videos);
|
||||
|
||||
return videos.sort((first, second) => first.path.localeCompare(second.path, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
}));
|
||||
}
|
||||
|
||||
async function walkLocalVideosDirectory(directoryPath, relativeDirectory, rootRealPath, videos) {
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = await fs.readdir(directoryPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
const label = relativeDirectory ? formatLocalVideoPath(relativeDirectory) : '.';
|
||||
logWarn(`local videos directory unreadable path=${label} error=${oneLine(error.message)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeDirectory ? path.join(relativeDirectory, entry.name) : entry.name;
|
||||
const absolutePath = path.join(directoryPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await walkLocalVideosDirectory(absolutePath, relativePath, rootRealPath, videos);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
await addLocalVideoEntry(absolutePath, relativePath, rootRealPath, videos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addLocalVideoEntry(absolutePath, relativePath, rootRealPath, videos) {
|
||||
let realPath;
|
||||
let stats;
|
||||
|
||||
try {
|
||||
realPath = await fs.realpath(absolutePath);
|
||||
stats = await fs.stat(realPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stats.isFile() || !isPathInside(realPath, rootRealPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayPath = formatLocalVideoPath(relativePath);
|
||||
const folder = formatLocalVideoPath(path.dirname(relativePath));
|
||||
|
||||
videos.push({
|
||||
path: displayPath,
|
||||
title: path.basename(relativePath),
|
||||
folder: folder === '.' ? '' : folder,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalVideosRealRoot() {
|
||||
if (!LOCAL_VIDEOS_ROOT) {
|
||||
return Promise.reject(new Error('Local video playback is not enabled.'));
|
||||
}
|
||||
|
||||
if (!localVideosRealRootPromise) {
|
||||
localVideosRealRootPromise = fs.realpath(LOCAL_VIDEOS_ROOT).catch((error) => {
|
||||
localVideosRealRootPromise = null;
|
||||
throw new Error(`Local videos directory is unavailable: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
return localVideosRealRootPromise;
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath, rootPath) {
|
||||
const relativePath = path.relative(rootPath, candidatePath);
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function formatLocalVideoPath(value) {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function formatLocalVideosRootName() {
|
||||
return path.basename(LOCAL_VIDEOS_ROOT) || LOCAL_VIDEOS_ROOT;
|
||||
}
|
||||
|
||||
function parseResolvedMediaUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error('Resolved media URL is empty.');
|
||||
@@ -479,6 +703,7 @@ async function resolvePlaybackSource(url) {
|
||||
return {
|
||||
kind: 'direct',
|
||||
url,
|
||||
originalUrl: url,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +712,7 @@ async function resolvePlaybackSource(url) {
|
||||
return {
|
||||
kind: 'youtube',
|
||||
url: resolved.url,
|
||||
originalUrl: url,
|
||||
headers: resolved.headers,
|
||||
};
|
||||
}
|
||||
@@ -742,7 +968,8 @@ function hasRecordedDuration(session) {
|
||||
}
|
||||
|
||||
function canBestEffortResumeWithoutDuration(session) {
|
||||
return session.sourceKind === 'youtube'
|
||||
return session.sourceKind === 'local'
|
||||
|| session.sourceKind === 'youtube'
|
||||
|| isLikelyRecordedMediaUrl(session.originalUrl)
|
||||
|| isLikelyRecordedMediaUrl(session.url);
|
||||
}
|
||||
@@ -757,6 +984,10 @@ function isLikelyRecordedMediaUrl(value) {
|
||||
}
|
||||
|
||||
function getSessionPlaybackConnectionMode(session) {
|
||||
if (session.sourceKind === 'local') {
|
||||
return 'split';
|
||||
}
|
||||
|
||||
if (session.forceSplitPlayback) {
|
||||
return 'split';
|
||||
}
|
||||
@@ -2465,6 +2696,15 @@ function toBufferView(chunk) {
|
||||
}
|
||||
|
||||
function createSourceInput(sessionId, kind) {
|
||||
const session = getSession(sessionId);
|
||||
|
||||
if (session?.sourceKind === 'local') {
|
||||
return {
|
||||
url: session.url,
|
||||
release: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
sourceTokens.set(token, { sessionId, kind, createdAt: Date.now() });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user