adds local play

This commit is contained in:
2026-06-14 18:10:10 -07:00
parent 53d8a6dc2e
commit 2db133dd1c
7 changed files with 755 additions and 14 deletions

View File

@@ -6,7 +6,7 @@ This project is a web video player for clients that can decode audio and still i
The UI intentionally has only two screens:
- URL entry screen with a stream URL input, a `Play` button, a `Queue` button, globally stored recently played URLs, and globally stored favorites.
- URL entry screen with a stream URL input, a `Play` button, a `Queue` button, an env-gated `Play Local` button when `LOCAL_VIDEOS` is set, globally stored recently played URLs, and globally stored favorites.
- Fullscreen player screen with JPEG frames drawn to a canvas and native audio playback through an `<audio>` element.
- Playback controls are overlay controls toggled by tapping/clicking the frame area, similar to YouTube.
- Do not reintroduce debug panels, frame counters, settings forms, explanatory marketing copy, or visible ffmpeg details into the normal UI.
@@ -26,7 +26,8 @@ The app is plain Node/Express plus browser JavaScript:
Main public endpoints:
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session.
- `POST /api/session`: validates the stream URL, stores recent URL, creates a short-lived playback session. Also accepts `localPath` for files selected from `LOCAL_VIDEOS`; local selections are not stored in recents.
- `GET /api/local-videos`: when `LOCAL_VIDEOS` is set, returns the recursive local file picker list.
- `GET /api/recent-urls`: returns global recent URL entries with `url`, redacted `displayUrl`, and `lastPlayedAt`.
- `POST /api/recent-urls`: validates a stream URL and stores it globally without creating a playback session.
- `GET /api/favorites`: returns global favorite entries with `title` and `url`.
@@ -189,6 +190,7 @@ Runtime:
- `RECENT_URL_LIMIT`: recent URL count, default `12`.
- `FAVORITES_PATH`: favorites JSON path.
- `FAVORITES_LIMIT`: favorites count, default `50`.
- `LOCAL_VIDEOS`: optional local video directory. When set, the UI shows `Play Local` and lists regular files under this directory recursively.
- `DEFAULT_FPS`: default frame rate, fallback `24`, clamped `1..30`.
- `DEFAULT_FRAME_WIDTH`: default maximum frame width, fallback `960`, clamped `160..1920`.
- `JPEG_QUALITY`: default JPEG quality, fallback `7`, clamped `2..18`; lower is better for ffmpeg `-q:v`.

View File

@@ -16,6 +16,8 @@ npm start
Open `http://localhost:3000`, paste a direct HTTP(S) stream URL, and click `Play`. Use `Queue` to add a URL to Recents without starting playback.
Set `LOCAL_VIDEOS=/path/to/videos` before starting the server to enable `Play Local`. The picker lets you navigate directories under that root, search the library, and play selected files through the server-side ffmpeg pipeline as MP3 audio plus JPEG canvas frames.
You need an `ffmpeg` binary with decoders for the stream's video and audio codecs. YouTube page links also need `yt-dlp`; Docker installs it automatically from the upstream master branch at image build time. If your stream is H.264 or HEVC, make sure your installed `ffmpeg` actually includes those decoders. You can point the app at different binaries with:
```sh
@@ -42,6 +44,8 @@ The app uses CPU decoding by default, so no video device is required. The compos
Recently played URLs and favorites are stored globally by the backend. In Docker Compose, they are persisted in the `frame-stream-data` named volume.
To use local playback in Docker, mount a host video directory into the container and set `LOCAL_VIDEOS` to the container path, for example `/app/local-videos`.
`ffmpeg` worker lifecycle, stderr warnings/errors, and source proxy open/close events are written to stdout/stderr, so they appear in `docker logs`. For more detail while debugging a stream, set `FFMPEG_LOG_LEVEL=info` in Docker Compose and run:
```sh

View File

@@ -33,10 +33,13 @@ services:
MAX_RELAY_BRANCH_QUEUE_BYTES: "8388608"
RECENT_URLS_PATH: /app/data/recent-urls.json
FAVORITES_PATH: /app/data/favorites.json
# Set this and mount a host directory below to enable Play Local.
# LOCAL_VIDEOS: /app/local-videos
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- frame-stream-data:/app/data
# - /path/on/host/videos:/app/local-videos:ro
# CPU decoding is the default and does not need device passthrough.
#

View File

@@ -5,6 +5,7 @@ const elements = {
url: document.querySelector('#stream-url'),
play: document.querySelector('#play'),
queue: document.querySelector('#queue'),
playLocal: document.querySelector('#play-local'),
entryMessage: document.querySelector('#entry-message'),
libraryPanel: document.querySelector('#library-panel'),
recentPanel: document.querySelector('#recent-panel'),
@@ -28,6 +29,13 @@ const elements = {
favoriteTitle: document.querySelector('#favorite-title'),
favoriteUrl: document.querySelector('#favorite-url'),
favoritesMessage: document.querySelector('#favorites-message'),
localModal: document.querySelector('#local-modal'),
closeLocal: document.querySelector('#close-local'),
localSearch: document.querySelector('#local-search'),
localBack: document.querySelector('#local-back'),
localPath: document.querySelector('#local-path'),
localList: document.querySelector('#local-list'),
localMessage: document.querySelector('#local-message'),
audio: document.querySelector('#audio'),
canvas: document.querySelector('#screen'),
stage: document.querySelector('#video-stage'),
@@ -78,6 +86,11 @@ const state = {
hideControlsTimer: 0,
recentUrls: [],
favorites: [],
localVideos: [],
localDirectory: '',
localVideosEnabled: false,
localVideosLoading: false,
localModalTrigger: null,
favoriteModalTrigger: null,
favoriteWizard: createFavoriteWizardState(),
playbackOffset: 0,
@@ -110,6 +123,45 @@ elements.queue.addEventListener('click', () => {
void queueCurrentUrl();
});
elements.playLocal.addEventListener('click', () => {
void openLocalModal();
});
elements.closeLocal.addEventListener('click', () => {
closeLocalModal();
});
elements.localSearch.addEventListener('input', () => {
renderLocalVideos();
});
elements.localBack.addEventListener('click', () => {
navigateLocalDirectory(getParentLocalDirectory(state.localDirectory));
});
elements.localList.addEventListener('click', (event) => {
const folderButton = event.target.closest('[data-local-folder]');
if (folderButton) {
navigateLocalDirectory(folderButton.dataset.localFolder);
return;
}
const button = event.target.closest('[data-local-index]');
if (!button) {
return;
}
const item = state.localVideos[Number(button.dataset.localIndex)];
if (!item) {
return;
}
void playLocalVideo(item);
});
async function playCurrentUrl() {
setEntryMessage('');
setFormBusy(true);
@@ -260,9 +312,24 @@ elements.favoritesModal.addEventListener('click', (event) => {
}
});
elements.localModal.addEventListener('click', (event) => {
if (event.target === elements.localModal) {
closeLocalModal();
}
});
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !elements.favoritesModal.hidden) {
if (event.key !== 'Escape') {
return;
}
if (!elements.favoritesModal.hidden) {
closeFavoritesModal();
return;
}
if (!elements.localModal.hidden) {
closeLocalModal();
}
});
@@ -1584,6 +1651,7 @@ function setFormBusy(isBusy) {
elements.url.disabled = isBusy;
elements.play.disabled = isBusy;
elements.queue.disabled = isBusy;
elements.playLocal.disabled = isBusy;
for (const button of elements.libraryPanel.querySelectorAll('button')) {
button.disabled = isBusy;
@@ -1594,6 +1662,7 @@ async function loadEntryLists() {
await Promise.all([
loadRecentUrls(),
loadFavorites(),
loadLocalVideos(),
]);
}
@@ -1665,6 +1734,317 @@ function renderFavorites() {
}
}
async function loadLocalVideos({ showLoading = false } = {}) {
if (showLoading) {
state.localVideosLoading = true;
renderLocalVideos();
}
try {
const response = await fetch('/api/local-videos', { cache: 'no-store' });
if (!response.ok) {
throw new Error('Failed to load local videos.');
}
const payload = await response.json();
state.localVideosEnabled = Boolean(payload.enabled);
state.localVideos = Array.isArray(payload.videos) ? payload.videos : [];
elements.playLocal.hidden = !state.localVideosEnabled;
if (!elements.localModal.hidden && payload.error) {
elements.localMessage.textContent = payload.error;
}
} catch {
state.localVideosEnabled = false;
state.localVideos = [];
elements.playLocal.hidden = true;
if (!elements.localModal.hidden) {
elements.localMessage.textContent = 'Failed to load local videos.';
}
} finally {
state.localVideosLoading = false;
if (!elements.localModal.hidden) {
renderLocalVideos();
}
}
}
async function openLocalModal() {
state.localModalTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null;
state.localDirectory = '';
elements.localSearch.value = '';
elements.localMessage.textContent = '';
elements.localModal.hidden = false;
elements.localModal.classList.add('is-open');
renderLocalVideos();
elements.localSearch.focus();
await loadLocalVideos({ showLoading: true });
}
function closeLocalModal({ restoreFocus = true } = {}) {
elements.localModal.hidden = true;
elements.localModal.classList.remove('is-open');
elements.localMessage.textContent = '';
elements.localSearch.value = '';
state.localDirectory = '';
if (restoreFocus) {
state.localModalTrigger?.focus();
}
state.localModalTrigger = null;
}
function renderLocalVideos() {
if (state.localVideosLoading) {
syncLocalNavigationHeader();
elements.localList.replaceChildren(createEmptyListMessage('Loading...'));
return;
}
const query = elements.localSearch.value.trim().toLowerCase();
syncLocalNavigationHeader(query);
if (query) {
renderLocalSearchResults(query);
return;
}
const { folders, files } = getLocalDirectoryEntries(state.localDirectory);
elements.localList.replaceChildren(
...folders.map((folder) => createLocalFolderButton(folder)),
...files.map(({ item, index }) => createLocalVideoButton(item, index)),
);
if (folders.length === 0 && files.length === 0) {
elements.localList.replaceChildren(createEmptyListMessage(state.localDirectory ? 'Folder is empty' : 'No local files'));
}
}
function renderLocalSearchResults(query) {
const matches = state.localVideos
.map((item, index) => ({ item, index }))
.filter(({ item }) => getLocalVideoSearchText(item).includes(query));
elements.localList.replaceChildren(
...matches.map(({ item, index }) => createLocalVideoButton(item, index, { showPath: true })),
);
if (matches.length === 0) {
elements.localList.replaceChildren(createEmptyListMessage('No matches'));
}
}
function getLocalDirectoryEntries(directory) {
const folderMap = new Map();
const files = [];
for (const [index, item] of state.localVideos.entries()) {
const itemPath = normalizeLocalPath(item.path);
const relativePath = getRelativeLocalPath(itemPath, directory);
if (!relativePath) {
continue;
}
const separatorIndex = relativePath.indexOf('/');
if (separatorIndex === -1) {
files.push({ item, index });
continue;
}
const folderName = relativePath.slice(0, separatorIndex);
const folderPath = directory ? `${directory}/${folderName}` : folderName;
const existing = folderMap.get(folderPath);
if (existing) {
existing.count += 1;
} else {
folderMap.set(folderPath, { name: folderName, path: folderPath, count: 1 });
}
}
return {
folders: [...folderMap.values()].sort(compareLocalEntryNames),
files: files.sort((first, second) => compareLocalEntryNames(
{ name: first.item.title || first.item.path || '' },
{ name: second.item.title || second.item.path || '' },
)),
};
}
function createLocalFolderButton(folder) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'local-video-item local-folder-item';
button.dataset.localFolder = folder.path;
const title = document.createElement('span');
title.className = 'local-video-title';
title.textContent = folder.name;
const meta = document.createElement('span');
meta.className = 'local-video-meta';
meta.textContent = `${folder.count} ${folder.count === 1 ? 'file' : 'files'}`;
button.append(title, meta);
return button;
}
function createLocalVideoButton(item, index, { showPath = false } = {}) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'local-video-item';
button.dataset.localIndex = String(index);
const title = document.createElement('span');
title.className = 'local-video-title';
title.textContent = item.title || item.path || 'Untitled';
const meta = document.createElement('span');
meta.className = 'local-video-meta';
meta.textContent = formatLocalVideoMeta(item, { showPath });
button.append(title, meta);
return button;
}
function navigateLocalDirectory(directory) {
state.localDirectory = normalizeLocalPath(directory);
elements.localSearch.value = '';
renderLocalVideos();
}
function getParentLocalDirectory(directory) {
const normalized = normalizeLocalPath(directory);
const separatorIndex = normalized.lastIndexOf('/');
return separatorIndex === -1 ? '' : normalized.slice(0, separatorIndex);
}
function syncLocalNavigationHeader(query = elements.localSearch.value.trim().toLowerCase()) {
elements.localBack.hidden = !state.localDirectory;
elements.localPath.textContent = query ? 'Search results' : state.localDirectory || 'Library';
}
function getRelativeLocalPath(itemPath, directory) {
const normalizedItemPath = normalizeLocalPath(itemPath);
const normalizedDirectory = normalizeLocalPath(directory);
if (!normalizedDirectory) {
return normalizedItemPath;
}
const prefix = `${normalizedDirectory}/`;
return normalizedItemPath.startsWith(prefix) ? normalizedItemPath.slice(prefix.length) : '';
}
function normalizeLocalPath(value) {
return String(value ?? '')
.split(/[\\/]+/)
.filter(Boolean)
.join('/');
}
function compareLocalEntryNames(first, second) {
return first.name.localeCompare(second.name, undefined, {
numeric: true,
sensitivity: 'base',
});
}
function getLocalVideoSearchText(item) {
return `${item.title ?? ''} ${item.folder ?? ''} ${item.path ?? ''}`.toLowerCase();
}
function formatLocalVideoMeta(item, { showPath = false } = {}) {
const parts = [];
if (showPath && item.path) {
parts.push(item.path);
} else if (item.folder) {
parts.push(item.folder);
}
const size = formatFileSize(item.size);
if (size) {
parts.push(size);
}
const modifiedAt = new Date(item.modifiedAt);
if (Number.isFinite(modifiedAt.getTime())) {
parts.push(modifiedAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}));
}
return parts.join(' ');
}
function formatFileSize(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) {
return '';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unitIndex]}`;
}
async function playLocalVideo(item) {
elements.localMessage.textContent = '';
setLocalModalBusy(true);
setFormBusy(true);
try {
stopSession({ showEntry: false });
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ localPath: item.path, width: getViewportFrameWidth() }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to create local playback session.');
}
closeLocalModal({ restoreFocus: false });
showPlayer();
startSession(payload);
} catch (error) {
stopSession({ showEntry: false });
elements.localMessage.textContent = error.message;
} finally {
setLocalModalBusy(false);
setFormBusy(false);
}
}
function setLocalModalBusy(isBusy) {
for (const field of elements.localModal.querySelectorAll('input, button')) {
field.disabled = isBusy;
}
}
function createEmptyListMessage(text) {
const message = document.createElement('p');
message.className = 'empty-list';

View File

@@ -21,6 +21,7 @@
required
>
<button id="queue" type="button" class="secondary-submit">Queue</button>
<button id="play-local" type="button" class="secondary-submit" hidden>Play Local</button>
<button id="play" type="submit">Play</button>
</form>
<div id="library-panel" class="library-panel" aria-label="Saved streams">
@@ -91,6 +92,23 @@
</div>
</form>
</div>
<div id="local-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="local-modal-title" hidden>
<div class="modal-panel local-modal-panel">
<div class="modal-heading">
<h2 id="local-modal-title">Local Videos</h2>
<button id="close-local" type="button" class="icon-button" aria-label="Close local video picker">&times;</button>
</div>
<label class="sr-only" for="local-search">Search local videos</label>
<input id="local-search" class="local-search" type="search" placeholder="Search files" autocomplete="off">
<div class="local-toolbar">
<button id="local-back" type="button" class="secondary-button local-back" hidden>Back</button>
<div id="local-path" class="local-path">Library</div>
</div>
<div id="local-list" class="local-list"></div>
<div id="local-message" class="modal-message" role="status" aria-live="polite"></div>
</div>
</div>
</main>
<script src="/app.js" type="module"></script>

View File

@@ -104,7 +104,8 @@ button:disabled {
.secondary-button,
.primary-button,
.icon-button,
.remove-favorite {
.remove-favorite,
.local-video-item {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--fg);
@@ -472,6 +473,95 @@ audio {
line-height: 1;
}
.local-search {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.9rem 0.95rem;
color: var(--fg);
background: rgba(255, 255, 255, 0.08);
outline: none;
}
.local-search::placeholder {
color: rgba(246, 241, 232, 0.48);
}
.local-search:focus {
border-color: rgba(232, 168, 79, 0.7);
}
.local-toolbar {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 2.7rem;
}
.local-back {
flex: 0 0 auto;
min-height: 2.45rem;
padding: 0.55rem 0.9rem;
font-weight: 800;
}
.local-path {
min-width: 0;
overflow: hidden;
color: var(--soft);
font-size: 0.86rem;
font-weight: 800;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.local-list {
display: grid;
gap: 0.45rem;
max-height: min(58vh, 32rem);
overflow: auto;
}
.local-video-item {
display: grid;
width: 100%;
gap: 0.22rem;
min-height: 4.35rem;
padding: 0.75rem 0.85rem;
border-radius: 8px;
text-align: left;
background: rgba(255, 255, 255, 0.06);
}
.local-folder-item {
border-color: rgba(232, 168, 79, 0.34);
}
.local-video-item:hover,
.local-video-item:focus {
border-color: rgba(246, 241, 232, 0.3);
background: rgba(255, 255, 255, 0.10);
outline: none;
}
.local-video-title,
.local-video-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.local-video-title {
font-weight: 800;
}
.local-video-meta {
color: rgba(246, 241, 232, 0.56);
font-size: 0.84rem;
}
.favorites-editor-list {
display: grid;
gap: 0.55rem;
@@ -665,6 +755,10 @@ audio {
width: 100%;
}
.local-back {
width: auto;
}
.modal-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));

View File

@@ -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() });