Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0508dc4
chore: ignore worktrees
Hazer Jun 22, 2026
ade362a
Add design spec: transcode ffmpeg resolution & player error surfacing
Hazer Jun 18, 2026
5dd1344
Add implementation plan: transcode ffmpeg resolution & player errors
Hazer Jun 18, 2026
964e549
Apply approved spec edits from review
Hazer Jun 19, 2026
4444d0b
fix(plan): correct server crate name (koko, not koko-server)
Hazer Jun 19, 2026
9a41e4b
feat(server): add ffmpeg_resolve module with allow-listed binary reso…
Hazer Jun 19, 2026
7ead9bd
refactor(server): detect_binary delegates to ffmpeg_resolve with vers…
Hazer Jun 19, 2026
9a0d271
feat(server): add SpawnTranscodeError + shared map_transcode_error
Hazer Jun 19, 2026
3633ce7
feat(server): resolve ffmpeg before spawn; structured SpawnTranscodeE…
Hazer Jun 19, 2026
e6b0045
feat(server): structured transcode errors + per-session error store
Hazer Jun 19, 2026
6d4b778
feat(server): add transcoding-tools discover + session-status routes
Hazer Jun 19, 2026
6b2a4b4
test(server): integration tests for discover + session-status routes
Hazer Jun 19, 2026
6656603
test(server): cover transcode arg generation, incl. spaces-in-path in…
Hazer Jun 19, 2026
346c681
test(server): add opt-in real-ffmpeg smoke test behind feature flag
Hazer Jun 19, 2026
0995609
feat(client): add transcoding discovery + session status API helpers
Hazer Jun 19, 2026
93cf527
feat(client): dynamic playback error copy via state.playbackError
Hazer Jun 19, 2026
8212486
feat(client): preflight ffmpeg availability before transcode playback
Hazer Jun 19, 2026
3ade75f
feat(client): recover structured transcode error via session status read
Hazer Jun 19, 2026
9d139b6
feat(client): Detect ffmpeg button with directory-paired radio picker
Hazer Jun 19, 2026
9c3bb91
test(server): update transcoding capability test for self-healing res…
Hazer Jun 19, 2026
bf25735
feat(server): probe curated encoders for ffmpeg discovery
Hazer Jun 19, 2026
0d3a57d
feat(client): redesign FFmpeg discovery as table with encoder tags + …
Hazer Jun 19, 2026
06e0fef
refactor(server): make resolver literal; keep smart search for Detect…
Hazer Jun 19, 2026
ac1e644
docs: add routes-module-split plan for delegation
Hazer Jun 19, 2026
2bbea1b
fix(client): show in-player error overlay on ffmpeg-missing preflight
Hazer Jun 19, 2026
c65c7e2
feat: ffprobe as first-class blocker, decision guard, auto re-probe
Hazer Jun 19, 2026
d50aea4
feat(server): restore settings-change ffprobe re-probe trigger
Hazer Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ crates/client-web/dist/

# dev temp files
.dev/

# git worktrees
.worktrees/
artifacts
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 66 additions & 0 deletions crates/client-web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,41 @@ export interface ServerCapabilities {
};
}

/** Validation result for a single transcoding binary (configured path or candidate). */
export interface BinaryProbe {
resolved_path?: string;
version?: string;
error?: string;
/** Transcoding-relevant encoders this ffmpeg supports (ffmpeg only). */
encoders?: string[];
}

/** One directory discovered to contain ffmpeg and/or ffprobe. */
export interface ToolCandidate {
directory: string;
ffmpeg?: BinaryProbe;
ffprobe?: BinaryProbe;
}

/** Result of the on-demand transcoding-tools discovery call. */
export interface ToolDiscoveryResponse {
configured_ffmpeg: BinaryProbe;
configured_ffprobe: BinaryProbe;
candidates: ToolCandidate[];
}

/** A structured transcode error recoverable via the session-status endpoint. */
export interface TranscodeErrorBody {
code: string;
message: string;
action?: string;
}

/** Status of a playback session's transcode. */
export interface SessionStatusResponse {
error?: TranscodeErrorBody;
}

export interface BootstrapUser {
id: number;
username: string;
Expand Down Expand Up @@ -415,8 +450,13 @@ export interface MediaHome {
collections: MediaCollectionSummary[];
}

/** Whether the source media has been analyzed by ffprobe. */
export type PlaybackAnalysisState = 'analyzed' | 'awaiting_analysis';

export interface PlaybackDecision {
item_id: number;
/** Whether the source media was probed; if 'awaiting_analysis', playback is blocked. */
analysis_state: PlaybackAnalysisState;
can_direct_play: boolean;
transcode_required: boolean;
reason: string;
Expand Down Expand Up @@ -1275,6 +1315,32 @@ export function deletePlaybackSession(sessionId: string): Promise<void> {
return requestJson<void>('DELETE', `/api/v1/sessions/${sessionId}`);
}

/** Discover ffmpeg/ffprobe candidates and validate the configured paths (admin). */
export function discoverTranscodingTools(): Promise<ToolDiscoveryResponse> {
return requestJson<ToolDiscoveryResponse>('POST', '/api/v1/system/tools/discover');
}

/** Read a playback session's transcode status (cheap map lookup, not a spawn). */
export function getSessionStatus(sessionId: string): Promise<SessionStatusResponse> {
return requestJson<SessionStatusResponse>('GET', `/api/v1/sessions/${encodeURIComponent(sessionId)}/status`);
}

/** Re-probe status: whether a job is running and how many files await analysis. */
export interface ReprobeStatusResponse {
in_progress: boolean;
pending_count: number;
}

/** Get re-probe status (running? how many files pending ffprobe analysis). */
export function getReprobeStatus(): Promise<ReprobeStatusResponse> {
return requestJson<ReprobeStatusResponse>('GET', '/api/v1/system/tools/reprobe');
}

/** Manually trigger a ffprobe re-probe of all unanalyzed media files (admin). */
export function triggerReprobe(): Promise<ReprobeStatusResponse> {
return requestJson<ReprobeStatusResponse>('POST', '/api/v1/system/tools/reprobe');
}

export function getArtworkUrl(itemId: number, kind: 'poster' | 'backdrop' | 'logo' = 'poster', revision?: number): string {
const params = new URLSearchParams({ kind });
if (typeof revision === 'number') {
Expand Down
2 changes: 1 addition & 1 deletion crates/client-web/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@
`;
}

function render(preserveScroll = true): void {

Check failure on line 428 in crates/client-web/src/app.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=LizardByte_Koko&issues=AZ7wJjBPaR-zuQzb_M7u&open=AZ7wJjBPaR-zuQzb_M7u&pullRequest=141
if (!state.isPlayerOpen && !state.activeTrailer) {
document.body.style.cursor = '';
}
Expand Down Expand Up @@ -468,7 +468,7 @@
${renderRail()}
<div class="main-shell">
<div class="main-shell-inner">
${state.error ? `<section class="panel error-panel page-panel">${escapeHtml(state.error)}</section>` : ''}
${state.error ? `<section class="panel error-panel page-panel">${escapeHtml(state.error)}${state.playbackError?.action === 'open_settings' ? `<button class="secondary-button" type="button" data-action="open-settings">Open settings</button>` : ''}</section>` : ''}

Check warning on line 471 in crates/client-web/src/app.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=LizardByte_Koko&issues=AZ7wJjBQaR-zuQzb_M7v&open=AZ7wJjBQaR-zuQzb_M7v&pullRequest=141

Check warning on line 471 in crates/client-web/src/app.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=LizardByte_Koko&issues=AZ7wJjBQaR-zuQzb_M7w&open=AZ7wJjBQaR-zuQzb_M7w&pullRequest=141
${renderCurrentPage()}
</div>
</div>
Expand Down
92 changes: 91 additions & 1 deletion crates/client-web/src/app/eventBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
selectedItemDefaultMetadataYear,
selectedItemExtras,
} from './itemPersonView';
import { buildSettingsFromForm } from './settingsView';
import { buildSettingsFromForm, renderDiscoverResults, renderPathValidation } from './settingsView';
import { setButtonBusy } from './ui';
import {
addLibrary,
Expand All @@ -52,6 +52,8 @@ import {
createUser,
deleteLibrary,
deleteMissingItems,
discoverTranscodingTools,
getReprobeStatus,
getItemMetadata,
getLogs,
getUsers,
Expand All @@ -71,6 +73,7 @@ import {
type MediaShelf,
type ScheduledTaskId,
type UpdateUserRequest,
triggerReprobe,
} from '../api';

/** Replaces a DOM subtree while preserving any coordinator-level patch behavior. */
Expand Down Expand Up @@ -527,6 +530,93 @@ function bindRenderEvents(context: AppEventBindingContext): void {
});
});

document.querySelectorAll<HTMLElement>('[data-action="open-settings"]').forEach((button) => {
button.addEventListener('click', () => {
navigateTo('/settings');
});
});

document.querySelector<HTMLButtonElement>('#detect-ffmpeg')?.addEventListener('click', async (event) => {
const button = event.currentTarget as HTMLButtonElement;
const resultsContainer = document.querySelector<HTMLElement>('#ffmpeg-discover-results');
const ffmpegInput = document.querySelector<HTMLInputElement>('input[name="ffmpeg_path"]');
const ffprobeInput = document.querySelector<HTMLInputElement>('input[name="ffprobe_path"]');
const ffmpegValidation = document.querySelector<HTMLElement>('#ffmpeg-path-validation');
const ffprobeValidation = document.querySelector<HTMLElement>('#ffprobe-path-validation');
if (!resultsContainer || !ffmpegInput || !ffprobeInput) {
return;
}
setButtonBusy(button, true);
try {
const discovery = await discoverTranscodingTools();
resultsContainer.innerHTML = renderDiscoverResults(discovery);
resultsContainer.hidden = false;

// Per-field validation detail, driven by the configured-path probes.
if (ffmpegValidation) {
ffmpegValidation.innerHTML = renderPathValidation(discovery.configured_ffmpeg, 'ffmpeg');
ffmpegValidation.hidden = !ffmpegValidation.innerHTML;
}
if (ffprobeValidation) {
ffprobeValidation.innerHTML = renderPathValidation(discovery.configured_ffprobe, 'ffprobe');
ffprobeValidation.hidden = !ffprobeValidation.innerHTML;
}

// [Use] buttons write <dir>/ffmpeg + <dir>/ffprobe into the path fields.
resultsContainer.querySelectorAll<HTMLButtonElement>('[data-use-directory]').forEach((useButton) => {
useButton.addEventListener('click', () => {
const dir = useButton.dataset.useDirectory;
if (!dir) {
return;
}
ffmpegInput.value = `${dir}/ffmpeg`;
ffprobeInput.value = `${dir}/ffprobe`;
});
});
} catch (error) {
resultsContainer.hidden = false;
resultsContainer.innerHTML = `<p class="muted">Detection failed: ${escapeHtml(error instanceof Error ? error.message : 'unknown error')}</p>`;
} finally {
setButtonBusy(button, false);
}
});

// Re-probe media info: show the button when there are unanalyzed files
// (ffprobe was missing during scan), and trigger the job on click.
const reprobeButton = document.querySelector<HTMLButtonElement>('#reprobe-media');
const reprobeStatus = document.querySelector<HTMLElement>('#reprobe-status');
if (reprobeButton && reprobeStatus) {
getReprobeStatus()
.then((status) => {
if (status.in_progress) {
reprobeStatus.hidden = false;
reprobeStatus.textContent = 'Re-probing media info is in progress… (see Dashboard → Activities).';
} else if (status.pending_count > 0) {
reprobeButton.hidden = false;
reprobeStatus.hidden = false;
reprobeStatus.textContent = `${status.pending_count} media file(s) were scanned without ffprobe and need re-analysis. Click to re-probe.`;
}
})
.catch((error) => {
console.warn('Failed to fetch reprobe status', error);
});

reprobeButton.addEventListener('click', async () => {
setButtonBusy(reprobeButton, true);
try {
await triggerReprobe();
reprobeStatus.hidden = false;
reprobeStatus.textContent = 'Re-probe started. Watch Dashboard → Activities for progress; playback unlocks as files are analyzed.';
reprobeButton.hidden = true;
} catch (error) {
reprobeStatus.hidden = false;
reprobeStatus.textContent = `Re-probe failed to start: ${escapeHtml(error instanceof Error ? error.message : 'unknown error')}`;
} finally {
setButtonBusy(reprobeButton, false);
}
});
}

document.querySelectorAll<HTMLButtonElement>('[data-provider-settings]').forEach((button) => {
button.addEventListener('click', () => {
const providerId = button.dataset.providerSettings;
Expand Down
46 changes: 44 additions & 2 deletions crates/client-web/src/app/playbackController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
deletePlaybackSession,
getArtworkUrl,
getItem,
getSessionStatus,
getSessionStreamUrl,
getWebClientProfile,
resolveApiUrl,
Expand Down Expand Up @@ -322,8 +323,8 @@ function renderMediaPlayerOverlay(): string {
<span class="loading-spinner player-loading-spinner" aria-hidden="true"></span>
</div>
<div class="player-error-indicator" aria-live="polite">
<strong>Playback could not start</strong>
<span>Try another audio track or start playback again.</span>
<strong>${escapeHtml(state.playbackError ? 'Playback failed' : 'Playback could not start')}</strong>
<span>${escapeHtml(state.playbackError?.message ?? 'Try another audio track or start playback again.')}</span>
</div>
<div class="player-idle-hit-area" aria-hidden="true"></div>
<div class="player-top-controls player-controls">
Expand Down Expand Up @@ -615,6 +616,8 @@ export async function startPlayback(item: MediaItemDetail, startMs: number): Pro
state.isPlayerOpen = true;
state.activeAudioStreamIndex = undefined;
state.isAudioTrackMenuOpen = false;
// Clear any stale playback error from a previous attempt.
state.playbackError = undefined;
render();

if (previousSession) {
Expand All @@ -627,6 +630,28 @@ export async function startPlayback(item: MediaItemDetail, startMs: number): Pro
item_id: item.id,
client_profile: getWebClientProfile(),
});

// Server-truth gate: if the source media was never analyzed by ffprobe
// (ffprobe was missing during scan), the decision is untrustworthy and the
// server returns analysis_state = 'awaiting_analysis'. Refuse to open a
// doomed player and surface the error instead. This replaces the old,
// buggy client-side capabilities-cache preflight.
const decision = state.activePlaybackSession.decision;
if (decision.analysis_state === 'awaiting_analysis') {
state.playbackError = {
code: 'media_not_analyzed',
message: decision.reason || 'This media has not been analyzed yet. Set the ffprobe path in Settings and re-probe media info.',
action: 'open_settings',
};
state.error = state.playbackError.message;
render();
// The player overlay covers the page banner, so toggle the in-player error
// class directly (the shell is mounted by the render() above).
document.querySelector<HTMLElement>('.media-player-shell')?.classList.remove('is-media-loading');
document.querySelector<HTMLElement>('.media-player-shell')?.classList.add('has-media-error');
return;
}

render();
}

Expand Down Expand Up @@ -1330,6 +1355,23 @@ export function bindPlayerProgress(): void {
player.addEventListener('error', () => {
setPlayerError();
console.error('Media playback failed', player.error);
const sessionId = state.activePlaybackSession?.session_id;
if (!sessionId) {
return;
}
// Best-effort: recover a structured error from the per-session store. This
// is a cheap map lookup on the server, not a transcode spawn. It only helps
// when the browser actually fires `error` (HTTP failures are unreliable).
void getSessionStatus(sessionId)
.then((status) => {
if (status.error) {
state.playbackError = status.error;
render();
}
})
.catch((error) => {
console.warn('Failed to fetch session status after playback error', error);
});
});
player.addEventListener('loadedmetadata', () => {
applyInitialDirectSeek();
Expand Down
Loading