* feat(playback): add per-user transcoding controls * fix(playback): enforce forced video transcode permission * chore: address transcode control review feedback * fix(playback): recheck transcode permission on audio switch --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
655 lines
20 KiB
TypeScript
655 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { usePlayerConfig } from "../context/PlayerConfigContext";
|
|
import { PlayerFetchError, playerFetch } from "../player-fetch";
|
|
import { describeTranscodingPolicyError } from "../playback-errors";
|
|
import {
|
|
useCodecDetection,
|
|
QUALITY_TO_RESOLUTION,
|
|
RESOLUTION_ORDER,
|
|
matchByTraits,
|
|
getPlaybackEnvironmentSnapshot,
|
|
} from "./useCodecDetection";
|
|
import { buildPlayerStreamUrl } from "../stream-url";
|
|
import type {
|
|
ChangeAudioResponse,
|
|
PlaybackSessionPlaybackInfo,
|
|
PlaybackSessionResponse,
|
|
PlayMethod,
|
|
PlayerFileVersion,
|
|
PlayerPlaybackVariant,
|
|
PlayerSubtitleInfo,
|
|
ResumeHints,
|
|
} from "../types";
|
|
|
|
interface PlaybackSessionState {
|
|
streamUrl: string | null;
|
|
playMethod: PlayMethod | null;
|
|
sessionId: string | null;
|
|
mediaFileId: number | null;
|
|
initialPosition: number;
|
|
audioTrackIndex: number;
|
|
durationSeconds: number | null;
|
|
subtitleUrls: PlayerSubtitleInfo[];
|
|
playbackInfo: PlaybackSessionPlaybackInfo | null;
|
|
loading: boolean;
|
|
replacing: boolean;
|
|
errorTitle: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
interface PlaybackSessionErrorState {
|
|
title: string;
|
|
message: string;
|
|
}
|
|
|
|
interface DownloadedSubtitle {
|
|
id: number;
|
|
media_file_id: number;
|
|
provider: string;
|
|
language: string;
|
|
format: string;
|
|
release_name: string;
|
|
score: number;
|
|
hearing_impaired: boolean;
|
|
}
|
|
|
|
interface UsePlaybackSessionResult extends PlaybackSessionState {
|
|
switchVersion: (fileId: number, currentPosition: number) => void;
|
|
switchAudioTrack: (index: number, currentPosition: number) => void;
|
|
refreshSubtitles: () => void;
|
|
}
|
|
|
|
export interface StartPlaybackRequestPayload {
|
|
file_id: number;
|
|
profile_id: string;
|
|
start_position?: number;
|
|
audio_track_index?: number;
|
|
codecs_video: string[];
|
|
codecs_audio: string[];
|
|
containers: string[];
|
|
max_resolution: string;
|
|
hdr: boolean;
|
|
supports_bitmap_subtitle_burn_in: boolean;
|
|
}
|
|
|
|
export function buildStartPlaybackRequestPayload({
|
|
targetFileId,
|
|
profileId,
|
|
position,
|
|
forceInitialPosition,
|
|
codecsVideo,
|
|
codecsAudio,
|
|
containers,
|
|
maxResolution,
|
|
hdr,
|
|
explicitAudioTrackIndex,
|
|
}: {
|
|
targetFileId: number;
|
|
profileId: string;
|
|
position: number;
|
|
forceInitialPosition: boolean;
|
|
codecsVideo: string[];
|
|
codecsAudio: string[];
|
|
containers: string[];
|
|
maxResolution: string;
|
|
hdr: boolean;
|
|
explicitAudioTrackIndex?: number | null;
|
|
}): StartPlaybackRequestPayload {
|
|
const payload: StartPlaybackRequestPayload = {
|
|
file_id: targetFileId,
|
|
profile_id: profileId,
|
|
codecs_video: codecsVideo,
|
|
codecs_audio: codecsAudio,
|
|
containers,
|
|
max_resolution: maxResolution,
|
|
hdr,
|
|
supports_bitmap_subtitle_burn_in: true,
|
|
};
|
|
|
|
if (forceInitialPosition || position > 0) {
|
|
payload.start_position = position;
|
|
}
|
|
if (explicitAudioTrackIndex != null && explicitAudioTrackIndex >= 0) {
|
|
payload.audio_track_index = explicitAudioTrackIndex;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function describePlaybackSessionError(
|
|
error: unknown,
|
|
fallbackMessage: string,
|
|
): PlaybackSessionErrorState {
|
|
const policyError = describeTranscodingPolicyError(error);
|
|
if (policyError) {
|
|
return policyError;
|
|
}
|
|
|
|
if (error instanceof PlayerFetchError) {
|
|
if (
|
|
error.status === 404 &&
|
|
error.code === "not_found" &&
|
|
error.message === "Source media file is missing"
|
|
) {
|
|
return {
|
|
title: "This video is no longer available",
|
|
message:
|
|
"The file needed to play it can't be found right now. Go back and try another version if one is available.",
|
|
};
|
|
}
|
|
|
|
if (error.status === 404 && error.code === "not_found") {
|
|
return {
|
|
title: "This item is no longer available",
|
|
message:
|
|
"The file needed to play this item can't be found right now. Go back and try another version if one is available.",
|
|
};
|
|
}
|
|
|
|
if (error.status === 403) {
|
|
return {
|
|
title: "Playback unavailable",
|
|
message: "You do not have permission to play this item.",
|
|
};
|
|
}
|
|
|
|
if (error.status >= 500) {
|
|
return {
|
|
title: "Playback unavailable",
|
|
message: "Silo could not start playback right now. Please try again.",
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: "Playback unavailable",
|
|
message: error.message || fallbackMessage,
|
|
};
|
|
}
|
|
|
|
if (error instanceof Error && error.message === "No compatible file version found") {
|
|
return {
|
|
title: "No compatible version found",
|
|
message: "Silo could not find a playable version for this device.",
|
|
};
|
|
}
|
|
|
|
if (error instanceof Error && error.message.trim().length > 0) {
|
|
return {
|
|
title: "Playback unavailable",
|
|
message: error.message,
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: "Playback unavailable",
|
|
message: fallbackMessage,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manages the playback session lifecycle:
|
|
* 1. On mount: detect codecs → select best version → POST /playback/start → get stream_url
|
|
* 2. switchVersion(): stop current session → start new session at same position
|
|
* 3. On unmount: DELETE /playback/{session_id} + keepalive fallback
|
|
*/
|
|
export function usePlaybackSession(
|
|
requestKey: string,
|
|
versions: PlayerFileVersion[],
|
|
playbackVariants: PlayerPlaybackVariant[] = [],
|
|
fileId?: number,
|
|
initialPosition = 0,
|
|
forceInitialPosition = false,
|
|
qualityPreference?: string | null,
|
|
resumeHints?: ResumeHints,
|
|
explicitAudioTrackIndex?: number | null,
|
|
): UsePlaybackSessionResult {
|
|
const config = usePlayerConfig();
|
|
const { capabilities, selectBestVersion } = useCodecDetection();
|
|
const [state, setState] = useState<PlaybackSessionState>({
|
|
streamUrl: null,
|
|
playMethod: null,
|
|
sessionId: null,
|
|
mediaFileId: null,
|
|
initialPosition: 0,
|
|
audioTrackIndex: 0,
|
|
durationSeconds: null,
|
|
subtitleUrls: [],
|
|
playbackInfo: null,
|
|
loading: true,
|
|
replacing: false,
|
|
errorTitle: null,
|
|
error: null,
|
|
});
|
|
|
|
const sessionIdRef = useRef<string | null>(null);
|
|
const stateRef = useRef(state);
|
|
const activeRequestKeyRef = useRef<string | null>(null);
|
|
const switchingRef = useRef(false);
|
|
const loadSequenceRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
stateRef.current = state;
|
|
}, [state]);
|
|
|
|
const createSessionState = useCallback(
|
|
async (targetFileId: number, position: number, forceStartPosition: boolean) => {
|
|
const profileId = config.getProfileId();
|
|
|
|
// Cap max_resolution with quality preference (use the lower of the two).
|
|
let effectiveMaxRes = capabilities.max_resolution;
|
|
if (qualityPreference && qualityPreference !== "auto") {
|
|
const prefRes = QUALITY_TO_RESOLUTION[qualityPreference];
|
|
if (prefRes) {
|
|
const screenRank = RESOLUTION_ORDER[effectiveMaxRes] ?? 0;
|
|
const prefRank = RESOLUTION_ORDER[prefRes] ?? 0;
|
|
if (prefRank > 0 && prefRank < screenRank) {
|
|
effectiveMaxRes = prefRes;
|
|
}
|
|
}
|
|
}
|
|
|
|
const body = JSON.stringify(
|
|
buildStartPlaybackRequestPayload({
|
|
targetFileId,
|
|
profileId: profileId ?? "",
|
|
position,
|
|
forceInitialPosition: forceStartPosition,
|
|
codecsVideo: capabilities.codecs_video,
|
|
codecsAudio: capabilities.codecs_audio,
|
|
containers: capabilities.containers,
|
|
maxResolution: effectiveMaxRes,
|
|
hdr: capabilities.hdr,
|
|
explicitAudioTrackIndex,
|
|
}),
|
|
);
|
|
|
|
const environment = getPlaybackEnvironmentSnapshot();
|
|
console.info("[playback/start] client capabilities", {
|
|
targetFileId,
|
|
qualityPreference: qualityPreference ?? "auto",
|
|
effectiveMaxRes,
|
|
capabilities,
|
|
environment,
|
|
});
|
|
|
|
const session = await playerFetch<PlaybackSessionResponse>(config, "/playback/start", {
|
|
method: "POST",
|
|
body,
|
|
});
|
|
|
|
// Append token as query param for native media elements
|
|
// that can't set Authorization headers.
|
|
const token = config.getAccessToken();
|
|
const restoredPosition = session.position ?? 0;
|
|
|
|
return {
|
|
streamUrl: buildPlayerStreamUrl(
|
|
config.apiBaseUrl,
|
|
session.stream_url,
|
|
token,
|
|
session.play_method,
|
|
restoredPosition,
|
|
),
|
|
playMethod: session.play_method,
|
|
sessionId: session.session_id,
|
|
mediaFileId: session.media_file_id,
|
|
initialPosition: restoredPosition,
|
|
audioTrackIndex: session.audio_track_index ?? 0,
|
|
durationSeconds: session.duration_seconds ?? null,
|
|
subtitleUrls: (session.subtitle_urls ?? []).map((s) => ({
|
|
...s,
|
|
url: buildPlayerStreamUrl(config.apiBaseUrl, s.url, token, "direct", 0),
|
|
font_bundle_url: s.font_bundle_url
|
|
? buildPlayerStreamUrl(config.apiBaseUrl, s.font_bundle_url, token, "direct", 0)
|
|
: undefined,
|
|
})),
|
|
playbackInfo: session.playback_info ?? null,
|
|
loading: false,
|
|
replacing: false,
|
|
errorTitle: null,
|
|
error: null,
|
|
} satisfies PlaybackSessionState;
|
|
},
|
|
[config, capabilities, explicitAudioTrackIndex, qualityPreference],
|
|
);
|
|
|
|
const stopSession = useCallback(
|
|
async (sessionId: string) => {
|
|
await playerFetch(config, `/playback/${sessionId}`, {
|
|
method: "DELETE",
|
|
});
|
|
},
|
|
[config],
|
|
);
|
|
|
|
const selectFileId = useCallback(
|
|
(preferredFileId?: number) => {
|
|
let selectedFileId: number | null | undefined = preferredFileId;
|
|
if (!selectedFileId && resumeHints?.lastFileId) {
|
|
const exact = versions.find((v) => v.file_id === resumeHints.lastFileId);
|
|
if (exact) selectedFileId = exact.file_id;
|
|
}
|
|
if (!selectedFileId && resumeHints) {
|
|
const traitMatch = matchByTraits(versions, resumeHints);
|
|
if (traitMatch) selectedFileId = traitMatch.file_id;
|
|
}
|
|
if (!selectedFileId) {
|
|
selectedFileId = selectDefaultVariantFile(
|
|
playbackVariants,
|
|
versions,
|
|
resumeHints,
|
|
qualityPreference,
|
|
selectBestVersion,
|
|
);
|
|
}
|
|
if (!selectedFileId) {
|
|
const best = selectBestVersion(versions, qualityPreference);
|
|
if (!best) {
|
|
return null;
|
|
}
|
|
selectedFileId = best.file_id;
|
|
}
|
|
|
|
return selectedFileId;
|
|
},
|
|
[playbackVariants, qualityPreference, resumeHints, selectBestVersion, versions],
|
|
);
|
|
|
|
const loadSession = useCallback(
|
|
async ({
|
|
preferredFileId,
|
|
position,
|
|
forceStartPosition,
|
|
allowPreserveExistingSessionOnError,
|
|
replacementErrorMessage,
|
|
initialErrorMessage,
|
|
}: {
|
|
preferredFileId?: number;
|
|
position: number;
|
|
forceStartPosition: boolean;
|
|
allowPreserveExistingSessionOnError: boolean;
|
|
replacementErrorMessage: string;
|
|
initialErrorMessage: string;
|
|
}) => {
|
|
const previousState = stateRef.current;
|
|
const previousSessionId = sessionIdRef.current;
|
|
const hasExistingSession = !!previousState.sessionId && !!previousState.streamUrl;
|
|
const loadSequence = ++loadSequenceRef.current;
|
|
|
|
setState((current) => ({
|
|
...current,
|
|
loading: !hasExistingSession,
|
|
replacing: hasExistingSession,
|
|
errorTitle: hasExistingSession ? current.errorTitle : null,
|
|
error: hasExistingSession ? current.error : null,
|
|
}));
|
|
|
|
try {
|
|
const selectedFileId = selectFileId(preferredFileId);
|
|
if (!selectedFileId) {
|
|
throw new Error("No compatible file version found");
|
|
}
|
|
|
|
const nextState = await createSessionState(selectedFileId, position, forceStartPosition);
|
|
|
|
if (loadSequence !== loadSequenceRef.current) {
|
|
await stopSession(nextState.sessionId!).catch(() => {
|
|
// Best effort cleanup for stale session starts.
|
|
});
|
|
return;
|
|
}
|
|
|
|
sessionIdRef.current = nextState.sessionId;
|
|
setState(nextState);
|
|
|
|
if (previousSessionId && previousSessionId !== nextState.sessionId) {
|
|
void stopSession(previousSessionId).catch(() => {
|
|
// Best effort — stale session will time out server-side.
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (loadSequence !== loadSequenceRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (hasExistingSession && allowPreserveExistingSessionOnError) {
|
|
console.error(replacementErrorMessage, err);
|
|
setState((current) => ({
|
|
...current,
|
|
loading: false,
|
|
replacing: false,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const nextError = describePlaybackSessionError(err, initialErrorMessage);
|
|
setState((current) => ({
|
|
...current,
|
|
loading: false,
|
|
replacing: false,
|
|
errorTitle: nextError.title,
|
|
error: nextError.message,
|
|
}));
|
|
}
|
|
},
|
|
[createSessionState, selectFileId, stopSession],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (activeRequestKeyRef.current === requestKey) {
|
|
return;
|
|
}
|
|
activeRequestKeyRef.current = requestKey;
|
|
|
|
void loadSession({
|
|
preferredFileId: fileId,
|
|
position: initialPosition,
|
|
forceStartPosition: forceInitialPosition,
|
|
allowPreserveExistingSessionOnError: false,
|
|
replacementErrorMessage: "Failed to replace playback request",
|
|
initialErrorMessage: "Failed to start playback",
|
|
});
|
|
}, [fileId, forceInitialPosition, initialPosition, loadSession, requestKey]);
|
|
|
|
// Clean up session on unmount.
|
|
useEffect(() => {
|
|
return () => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
|
|
const token = config.getAccessToken();
|
|
const profileId = config.getProfileId();
|
|
const url = `${config.apiBaseUrl}/playback/${sid}`;
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
if (profileId) headers["X-Profile-Id"] = profileId;
|
|
const profileToken = config.getProfileToken?.();
|
|
if (profileToken) headers["X-Profile-Token"] = profileToken;
|
|
|
|
// sendBeacon doesn't support DELETE, so use fetch with keepalive.
|
|
fetch(url, {
|
|
method: "DELETE",
|
|
headers,
|
|
keepalive: true,
|
|
}).catch(() => {
|
|
// Best effort — if fetch fails, session will time out server-side.
|
|
});
|
|
};
|
|
}, [config]);
|
|
|
|
const refreshSubtitles = useCallback(() => {
|
|
const mediaFile = state.mediaFileId;
|
|
const sid = sessionIdRef.current;
|
|
if (!mediaFile || !sid) return;
|
|
|
|
(async () => {
|
|
try {
|
|
const resp = await playerFetch<{ subtitles: DownloadedSubtitle[] }>(
|
|
config,
|
|
`/subtitles/${mediaFile}`,
|
|
);
|
|
const downloaded = resp.subtitles ?? [];
|
|
if (downloaded.length === 0) return;
|
|
|
|
setState((prev) => {
|
|
// Filter out any previously added downloaded tracks
|
|
const existing = prev.subtitleUrls.filter((s) => s.source !== "downloaded");
|
|
const baseIndex = existing.length > 0 ? Math.max(...existing.map((s) => s.index)) + 1 : 0;
|
|
const token = config.getAccessToken();
|
|
const newTracks: PlayerSubtitleInfo[] = downloaded.map((dl, i) => ({
|
|
index: baseIndex + i,
|
|
media_file_id: mediaFile,
|
|
id: dl.id,
|
|
language: dl.language,
|
|
codec: dl.format,
|
|
label: `${dl.release_name} (${dl.provider})`,
|
|
source: "downloaded" as const,
|
|
hearing_impaired: dl.hearing_impaired,
|
|
url: buildPlayerStreamUrl(
|
|
config.apiBaseUrl,
|
|
`/stream/${sid}/subtitles/${baseIndex + i}`,
|
|
token,
|
|
"direct",
|
|
0,
|
|
),
|
|
}));
|
|
return { ...prev, subtitleUrls: [...existing, ...newTracks] };
|
|
});
|
|
} catch {
|
|
// Best effort — subtitle refresh failure shouldn't disrupt playback.
|
|
}
|
|
})();
|
|
}, [config, state.mediaFileId]);
|
|
|
|
const switchAudioTrack = useCallback(
|
|
(index: number, currentPosition: number) => {
|
|
const sid = sessionIdRef.current;
|
|
if (!sid) return;
|
|
|
|
(async () => {
|
|
try {
|
|
const resp = await playerFetch<ChangeAudioResponse>(config, `/playback/${sid}/audio`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
audio_track_index: index,
|
|
position: currentPosition,
|
|
}),
|
|
});
|
|
|
|
const token = config.getAccessToken();
|
|
setState((prev) => ({
|
|
...prev,
|
|
streamUrl: buildPlayerStreamUrl(
|
|
config.apiBaseUrl,
|
|
resp.stream_url,
|
|
token,
|
|
resp.play_method,
|
|
currentPosition,
|
|
),
|
|
playMethod: resp.play_method,
|
|
audioTrackIndex: resp.audio_track_index,
|
|
playbackInfo: resp.playback_info ?? prev.playbackInfo,
|
|
initialPosition: currentPosition,
|
|
}));
|
|
} catch (err) {
|
|
console.error("Failed to switch audio track:", err);
|
|
}
|
|
})();
|
|
},
|
|
[config],
|
|
);
|
|
|
|
const switchVersion = useCallback(
|
|
(newFileId: number, currentPosition: number) => {
|
|
if (switchingRef.current) return;
|
|
if (newFileId === state.mediaFileId) return;
|
|
switchingRef.current = true;
|
|
|
|
(async () => {
|
|
try {
|
|
await loadSession({
|
|
preferredFileId: newFileId,
|
|
position: currentPosition,
|
|
forceStartPosition: false,
|
|
allowPreserveExistingSessionOnError: true,
|
|
replacementErrorMessage: "Failed to switch playback version",
|
|
initialErrorMessage: "Failed to switch version",
|
|
});
|
|
} finally {
|
|
switchingRef.current = false;
|
|
}
|
|
})();
|
|
},
|
|
[loadSession, state.mediaFileId],
|
|
);
|
|
|
|
return { ...state, switchVersion, switchAudioTrack, refreshSubtitles };
|
|
}
|
|
|
|
function selectDefaultVariantFile(
|
|
playbackVariants: PlayerPlaybackVariant[],
|
|
versions: PlayerFileVersion[],
|
|
resumeHints?: ResumeHints,
|
|
qualityPreference?: string | null,
|
|
selectBestVersionFn?: (
|
|
versions: PlayerFileVersion[],
|
|
qualityPreference?: string | null,
|
|
) => PlayerFileVersion | null,
|
|
): number | null {
|
|
if (playbackVariants.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let candidateVariants = playbackVariants;
|
|
if (
|
|
resumeHints?.lastEditionKey &&
|
|
playbackVariants.some((variant) => variant.edition_key === resumeHints.lastEditionKey)
|
|
) {
|
|
candidateVariants = playbackVariants.filter(
|
|
(variant) => variant.edition_key === resumeHints.lastEditionKey,
|
|
);
|
|
} else if (playbackVariants.some((variant) => !variant.edition_key)) {
|
|
candidateVariants = playbackVariants.filter((variant) => !variant.edition_key);
|
|
}
|
|
|
|
let selectedVariant: PlayerPlaybackVariant | null = null;
|
|
let selectedVersion: PlayerFileVersion | null = null;
|
|
|
|
for (const variant of candidateVariants) {
|
|
const firstPart = [...(variant.parts ?? [])].sort((a, b) => a.part_index - b.part_index)[0];
|
|
if (!firstPart) {
|
|
continue;
|
|
}
|
|
|
|
let candidateVersion: PlayerFileVersion | null =
|
|
firstPart.default_file_id != null
|
|
? (versions.find((version) => version.file_id === firstPart.default_file_id) ?? null)
|
|
: null;
|
|
if (!candidateVersion && selectBestVersionFn) {
|
|
candidateVersion = selectBestVersionFn(firstPart.versions ?? [], qualityPreference);
|
|
}
|
|
if (!candidateVersion) {
|
|
continue;
|
|
}
|
|
|
|
if (!selectedVariant || !selectedVersion) {
|
|
selectedVariant = variant;
|
|
selectedVersion = candidateVersion;
|
|
continue;
|
|
}
|
|
|
|
if (variant.edition_key && !selectedVariant.edition_key) {
|
|
continue;
|
|
}
|
|
|
|
const winner = selectBestVersionFn?.([selectedVersion, candidateVersion], qualityPreference);
|
|
if (winner?.file_id === candidateVersion.file_id) {
|
|
selectedVariant = variant;
|
|
selectedVersion = candidateVersion;
|
|
}
|
|
}
|
|
|
|
return selectedVersion?.file_id ?? null;
|
|
}
|