final dev

This commit is contained in:
2026-03-11 11:56:17 +00:00
parent 2fdf54d2e6
commit 7979b353aa
18 changed files with 3651 additions and 440 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { InputText } from 'primereact/inputtext';
@@ -32,6 +32,23 @@ function StatusBadge({ status }) {
return <span className={`cron-status cron-status--${info.cls}`}>{info.label}</span>;
}
function normalizeActiveCronRuns(rawPayload) {
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
const active = Array.isArray(payload.active) ? payload.active : [];
return active
.map((item) => (item && typeof item === 'object' ? item : null))
.filter(Boolean)
.filter((item) => String(item.type || '').trim().toLowerCase() === 'cron')
.map((item) => ({
id: Number(item.id),
cronJobId: Number(item.cronJobId || 0),
currentStep: String(item.currentStep || '').trim() || null,
currentScriptName: String(item.currentScriptName || '').trim() || null,
startedAt: item.startedAt || null
}))
.filter((item) => Number.isFinite(item.cronJobId) && item.cronJobId > 0);
}
const EMPTY_FORM = {
name: '',
cronExpression: '',
@@ -50,6 +67,7 @@ export default function CronJobsTab({ onWsMessage }) {
const [loading, setLoading] = useState(false);
const [scripts, setScripts] = useState([]);
const [chains, setChains] = useState([]);
const [activeCronRuns, setActiveCronRuns] = useState([]);
// Editor-Dialog
const [editorOpen, setEditorOpen] = useState(false);
@@ -76,14 +94,18 @@ export default function CronJobsTab({ onWsMessage }) {
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [cronResp, scriptsResp, chainsResp] = await Promise.allSettled([
const [cronResp, scriptsResp, chainsResp, runtimeResp] = await Promise.allSettled([
api.getCronJobs(),
api.getScripts(),
api.getScriptChains()
api.getScriptChains(),
api.getRuntimeActivities()
]);
if (cronResp.status === 'fulfilled') setJobs(cronResp.value?.jobs || []);
if (scriptsResp.status === 'fulfilled') setScripts(scriptsResp.value?.scripts || []);
if (chainsResp.status === 'fulfilled') setChains(chainsResp.value?.chains || []);
if (runtimeResp.status === 'fulfilled') {
setActiveCronRuns(normalizeActiveCronRuns(runtimeResp.value));
}
} finally {
setLoading(false);
}
@@ -93,6 +115,36 @@ export default function CronJobsTab({ onWsMessage }) {
loadAll();
}, [loadAll]);
useEffect(() => {
let cancelled = false;
const refreshRuntime = async () => {
try {
const response = await api.getRuntimeActivities();
if (!cancelled) {
setActiveCronRuns(normalizeActiveCronRuns(response));
}
} catch (_error) {
// ignore polling errors
}
};
const interval = setInterval(refreshRuntime, 2500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const activeCronRunByJobId = useMemo(() => {
const map = new Map();
for (const item of activeCronRuns) {
if (!item?.cronJobId) {
continue;
}
map.set(item.cronJobId, item);
}
return map;
}, [activeCronRuns]);
// WebSocket: Cronjob-Updates empfangen
useEffect(() => {
if (!onWsMessage) return;
@@ -279,6 +331,7 @@ export default function CronJobsTab({ onWsMessage }) {
<div className="cron-list">
{jobs.map((job) => {
const isBusy = busyId === job.id;
const activeCronRun = activeCronRunByJobId.get(Number(job.id)) || null;
return (
<div key={job.id} className={`cron-item${job.enabled ? '' : ' cron-item--disabled'}`}>
<div className="cron-item-header">
@@ -305,6 +358,17 @@ export default function CronJobsTab({ onWsMessage }) {
<span className="cron-meta-label">Nächster Lauf:</span>
<span className="cron-meta-value">{formatDateTime(job.nextRunAt)}</span>
</span>
{activeCronRun ? (
<span className="cron-meta-entry">
<span className="cron-meta-label">Aktuell:</span>
<span className="cron-meta-value">
<StatusBadge status="running" />
{activeCronRun.currentScriptName
? `Skript: ${activeCronRun.currentScriptName}`
: (activeCronRun.currentStep || 'Ausführung läuft')}
</span>
</span>
) : null}
</div>
<div className="cron-item-toggles">

View File

@@ -54,6 +54,127 @@ function ScriptSummarySection({ title, summary }) {
);
}
function normalizeIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
continue;
}
const id = Math.trunc(parsed);
const key = String(id);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(id);
}
return output;
}
function shellQuote(value) {
const raw = String(value ?? '');
if (raw.length === 0) {
return "''";
}
if (/^[A-Za-z0-9_./:=,+-]+$/.test(raw)) {
return raw;
}
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
function buildExecutedHandBrakeCommand(handbrakeInfo) {
const cmd = String(handbrakeInfo?.cmd || '').trim();
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
if (!cmd) {
return null;
}
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
}
function buildConfiguredScriptAndChainSelection(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
const scriptNameById = new Map();
const chainNameById = new Map();
const addScriptHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || scriptNameById.has(id)) {
return;
}
scriptNameById.set(id, name);
};
const addChainHint = (idValue, nameValue) => {
const id = normalizeIdList([idValue])[0] || null;
const name = String(nameValue || '').trim();
if (!id || !name || chainNameById.has(id)) {
return;
}
chainNameById.set(id, name);
};
const addScriptHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addScriptHint(item?.id ?? item?.scriptId, item?.name ?? item?.scriptName);
}
};
const addChainHintsFromList = (list) => {
for (const item of (Array.isArray(list) ? list : [])) {
addChainHint(item?.id ?? item?.chainId, item?.name ?? item?.chainName);
}
};
addScriptHintsFromList(plan?.preEncodeScripts);
addScriptHintsFromList(plan?.postEncodeScripts);
addChainHintsFromList(plan?.preEncodeChains);
addChainHintsFromList(plan?.postEncodeChains);
const scriptSummaries = [handbrakeInfo?.preEncodeScripts, handbrakeInfo?.postEncodeScripts];
for (const summary of scriptSummaries) {
const results = Array.isArray(summary?.results) ? summary.results : [];
for (const result of results) {
addScriptHint(result?.scriptId, result?.scriptName);
addChainHint(result?.chainId, result?.chainName);
}
}
const preScriptIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeScriptIds) ? plan.preEncodeScriptIds : []),
...(Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const postScriptIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeScriptIds) ? plan.postEncodeScriptIds : []),
...(Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts.map((item) => item?.id ?? item?.scriptId) : [])
]);
const preChainIds = normalizeIdList([
...(Array.isArray(plan?.preEncodeChainIds) ? plan.preEncodeChainIds : []),
...(Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
const postChainIds = normalizeIdList([
...(Array.isArray(plan?.postEncodeChainIds) ? plan.postEncodeChainIds : []),
...(Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains.map((item) => item?.id ?? item?.chainId) : [])
]);
return {
preScriptIds,
postScriptIds,
preChainIds,
postChainIds,
preScripts: preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
postScripts: postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`),
preChains: preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
postChains: postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`),
scriptCatalog: Array.from(scriptNameById.entries()).map(([id, name]) => ({ id, name })),
chainCatalog: Array.from(chainNameById.entries()).map(([id, name]) => ({ id, name }))
};
}
function resolveMediaType(job) {
const candidates = [
job?.mediaType,
@@ -205,6 +326,25 @@ export default function JobDetailDialog({
: (mediaType === 'dvd' ? 'DVD' : 'Sonstiges Medium');
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
|| configuredSelection.preChainIds.length > 0
|| configuredSelection.postChainIds.length > 0;
const reviewPreEncodeItems = [
...configuredSelection.preScriptIds.map((id) => ({ type: 'script', id })),
...configuredSelection.preChainIds.map((id) => ({ type: 'chain', id }))
];
const reviewPostEncodeItems = [
...configuredSelection.postScriptIds.map((id) => ({ type: 'script', id })),
...configuredSelection.postChainIds.map((id) => ({ type: 'chain', id }))
];
const encodePlanUserPreset = job?.encodePlan?.userPreset && typeof job.encodePlan.userPreset === 'object'
? job.encodePlan.userPreset
: null;
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
return (
<Dialog
@@ -335,6 +475,42 @@ export default function JobDetailDialog({
</div>
</section>
{hasConfiguredSelection || encodePlanUserPreset ? (
<section className="job-meta-block job-meta-block-full">
<h4>Hinterlegte Encode-Auswahl</h4>
<div className="job-configured-selection-grid">
<div>
<strong>Pre-Encode Skripte:</strong> {configuredSelection.preScripts.length > 0 ? configuredSelection.preScripts.join(' | ') : '-'}
</div>
<div>
<strong>Pre-Encode Ketten:</strong> {configuredSelection.preChains.length > 0 ? configuredSelection.preChains.join(' | ') : '-'}
</div>
<div>
<strong>Post-Encode Skripte:</strong> {configuredSelection.postScripts.length > 0 ? configuredSelection.postScripts.join(' | ') : '-'}
</div>
<div>
<strong>Post-Encode Ketten:</strong> {configuredSelection.postChains.length > 0 ? configuredSelection.postChains.join(' | ') : '-'}
</div>
<div className="job-meta-col-span-2">
<strong>User-Preset:</strong>{' '}
{encodePlanUserPreset
? `${encodePlanUserPreset.name || '-'} | Preset=${encodePlanUserPreset.handbrakePreset || '-'} | ExtraArgs=${encodePlanUserPreset.extraArgs || '-'}`
: '-'}
</div>
</div>
</section>
) : null}
{executedHandBrakeCommand ? (
<section className="job-meta-block job-meta-block-full">
<h4>Ausgeführter Encode-Befehl</h4>
<div className="handbrake-command-preview">
<small><strong>HandBrakeCLI (tatsächlich gestartet):</strong></small>
<pre>{executedHandBrakeCommand}</pre>
</div>
</section>
) : null}
{(job.handbrakeInfo?.preEncodeScripts?.configured > 0 || job.handbrakeInfo?.postEncodeScripts?.configured > 0) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Skripte</h4>
@@ -356,7 +532,18 @@ export default function JobDetailDialog({
{job.encodePlan ? (
<>
<h4>Mediainfo-Prüfung (Auswertung)</h4>
<MediaInfoReviewPanel review={job.encodePlan} />
<MediaInfoReviewPanel
review={job.encodePlan}
commandOutputPath={job.output_path || null}
availableScripts={configuredSelection.scriptCatalog}
availableChains={configuredSelection.chainCatalog}
preEncodeItems={reviewPreEncodeItems}
postEncodeItems={reviewPostEncodeItems}
userPresets={reviewUserPresets}
selectedUserPresetId={Number.isFinite(encodePlanUserPresetId) && encodePlanUserPresetId > 0
? Math.trunc(encodePlanUserPresetId)
: null}
/>
</>
) : null}

View File

@@ -684,6 +684,14 @@ function normalizeScriptId(value) {
return Math.trunc(parsed);
}
function normalizeChainId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeScriptIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
@@ -762,8 +770,8 @@ export default function MediaInfoReviewPanel({
.filter((item) => item.id !== null && item.name.length > 0);
const scriptById = new Map(scriptCatalog.map((item) => [item.id, item]));
const chainCatalog = (Array.isArray(availableChains) ? availableChains : [])
.map((item) => ({ id: Number(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => Number.isFinite(item.id) && item.id > 0 && item.name.length > 0);
.map((item) => ({ id: normalizeChainId(item?.id), name: String(item?.name || '').trim() }))
.filter((item) => item.id !== null && item.name.length > 0);
const chainById = new Map(chainCatalog.map((item) => [item.id, item]));
const makeHandleDrop = (items, onReorder) => (event, targetIndex) => {
@@ -884,13 +892,29 @@ export default function MediaInfoReviewPanel({
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
preEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
preEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
preEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`pre-item-${rowIndex}-${item.type}-${item.id}`}
@@ -926,7 +950,15 @@ export default function MediaInfoReviewPanel({
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePreEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePreEncodeItem?.(rowIndex)} />
</>
@@ -980,13 +1012,29 @@ export default function MediaInfoReviewPanel({
? (scriptObj?.name || `Skript #${item.id}`)
: (chainObj?.name || `Kette #${item.id}`);
const usedScriptIds = new Set(
postEncodeItems.filter((it, i) => it.type === 'script' && i !== rowIndex).map((it) => String(normalizeScriptId(it.id)))
postEncodeItems
.filter((it, i) => it.type === 'script' && i !== rowIndex)
.map((it) => normalizeScriptId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const usedChainIds = new Set(
postEncodeItems
.filter((it, i) => it.type === 'chain' && i !== rowIndex)
.map((it) => normalizeChainId(it.id))
.filter((id) => id !== null)
.map((id) => String(id))
);
const scriptOptions = scriptCatalog.map((s) => ({
label: s.name,
value: s.id,
disabled: usedScriptIds.has(String(s.id))
}));
const chainOptions = chainCatalog.map((c) => ({
label: c.name,
value: c.id,
disabled: usedChainIds.has(String(c.id))
}));
return (
<div
key={`post-item-${rowIndex}-${item.type}-${item.id}`}
@@ -1022,7 +1070,15 @@ export default function MediaInfoReviewPanel({
className="full-width"
/>
) : (
<span className="post-script-chain-name">{name}</span>
<Dropdown
value={normalizeChainId(item.id)}
options={chainOptions}
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
onChange={(event) => onChangePostEncodeItem?.(rowIndex, 'chain', event.value)}
className="full-width"
/>
)}
<Button icon="pi pi-times" severity="danger" outlined onClick={() => onRemovePostEncodeItem?.(rowIndex)} />
</>

View File

@@ -98,6 +98,46 @@ function normalizeChainId(value) {
return Math.trunc(parsed);
}
function normalizeMediaProfile(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
return 'bluray';
}
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
return 'dvd';
}
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
return 'other';
}
return null;
}
function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
const device = context?.device && typeof context.device === 'object' ? context.device : {};
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
const candidates = [
context?.mediaProfile,
context?.media_profile,
review?.mediaProfile,
review?.media_profile,
device?.mediaProfile,
device?.media_profile,
device?.profile,
device?.type
];
for (const candidate of candidates) {
const normalized = normalizeMediaProfile(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -115,6 +155,7 @@ function isBurnedSubtitleTrack(track) {
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
@@ -122,15 +163,27 @@ function buildDefaultTrackSelection(review) {
continue;
}
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
const isEncodeInputTitle = Boolean(
title?.selectedForEncode
|| title?.encodeInput
|| (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId)
);
const audioSelectionSource = isEncodeInputTitle
? audioTracks.filter((track) => Boolean(track?.selectedForEncode))
: audioTracks.filter((track) => Boolean(track?.selectedByRule));
const subtitleSelectionSource = isEncodeInputTitle
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
audioSelectionSource.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
subtitleSelectionSource
.filter((track) => !isBurnedSubtitleTrack(track))
.map((track) => track?.id)
)
};
@@ -235,10 +288,9 @@ export default function PipelineStatusCard({
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = String(pipeline?.context?.mediaProfile || '').trim().toLowerCase() || null;
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
@@ -319,8 +371,16 @@ export default function PipelineStatusCard({
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
]);
setSelectedUserPresetId(null);
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
}, [
mediaInfoReview?.encodeInputTitleId,
mediaInfoReview?.generatedAt,
mediaInfoReview?.reviewConfirmedAt,
mediaInfoReview?.prefilledFromPreviousRunAt,
mediaInfoReview?.userPreset?.id,
retryJobId
]);
useEffect(() => {
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
@@ -348,10 +408,11 @@ export default function PipelineStatusCard({
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all';
if (!jobMediaProfile) {
return true;
}
return p.mediaType === 'all' || p.mediaType === jobMediaProfile;
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
});
const canStartReadyJob = isPreRipReview
? Boolean(retryJobId)
@@ -592,12 +653,6 @@ export default function PipelineStatusCard({
icon="pi pi-play"
severity="success"
onClick={async () => {
const requiresAutoConfirm = !reviewConfirmed;
if (!requiresAutoConfirm) {
await onStart(retryJobId);
return;
}
const {
encodeTitleId,
selectedTrackSelection,