mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: introduce health checks and system status page (#885)
This commit is contained in:
committed by
GitHub
parent
663a849be8
commit
03f57e0ed5
@@ -34,7 +34,7 @@ import { metadataApiRouter } from './metadataApi.js';
|
||||
import { plexSettingsRouter } from './plexSettingsApi.js';
|
||||
import { programmingApi } from './programmingApi.js';
|
||||
import { sessionApiRouter } from './sessionApi.js';
|
||||
import { systemSettingsRouter } from './systemSettingsApi.js';
|
||||
import { systemApiRouter } from './systemApi.js';
|
||||
import { tasksApiRouter } from './tasksApi.js';
|
||||
import { xmlTvSettingsRouter } from './xmltvSettingsApi.js';
|
||||
|
||||
@@ -63,7 +63,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
.register(plexSettingsRouter)
|
||||
.register(xmlTvSettingsRouter)
|
||||
.register(hdhrSettingsRouter)
|
||||
.register(systemSettingsRouter)
|
||||
.register(systemApiRouter)
|
||||
.register(guideRouter)
|
||||
.register(jellyfinApiRouter)
|
||||
.register(sessionApiRouter);
|
||||
@@ -90,7 +90,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
}
|
||||
return res.send({
|
||||
tunarr: tunarrVersion,
|
||||
ffmpeg: v,
|
||||
ffmpeg: v.versionString,
|
||||
nodejs: process.version.replace('v', ''),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -8,16 +8,23 @@ import {
|
||||
import { BackupSettings, BackupSettingsSchema } from '@tunarr/types/schemas';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { DeepReadonly, Writable } from 'ts-essentials';
|
||||
import { scheduleBackupJobs } from '../services/scheduler';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType';
|
||||
import { getEnvironmentLogLevel } from '../util/logging/LoggerFactory';
|
||||
import { getDefaultLogLevel } from '../util/defaults';
|
||||
import { z } from 'zod';
|
||||
import { scheduleBackupJobs } from '../services/scheduler.js';
|
||||
import { FixersByName } from '../tasks/fixers/index.js';
|
||||
import { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import { getDefaultLogLevel } from '../util/defaults.js';
|
||||
import { ifDefined } from '../util/index.js';
|
||||
import { getEnvironmentLogLevel } from '../util/logging/LoggerFactory.js';
|
||||
|
||||
export const systemSettingsRouter: RouterPluginAsyncCallback = async (
|
||||
export const systemApiRouter: RouterPluginAsyncCallback = async (
|
||||
fastify,
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
) => {
|
||||
fastify.get('/system/health', async (req, res) => {
|
||||
const results = await req.serverCtx.healthCheckService.runAll();
|
||||
return res.send(results);
|
||||
});
|
||||
|
||||
fastify.get(
|
||||
'/system/settings',
|
||||
{
|
||||
@@ -33,6 +40,38 @@ export const systemSettingsRouter: RouterPluginAsyncCallback = async (
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get('/system/state', async (req, res) => {
|
||||
return res.send(req.serverCtx.settings.migrationState);
|
||||
});
|
||||
|
||||
fastify.post(
|
||||
'/system/fixers/:fixerId/run',
|
||||
{
|
||||
schema: {
|
||||
params: z.object({
|
||||
fixerId: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const fixer = FixersByName[req.params.fixerId];
|
||||
if (!fixer) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(`Unknown fixer ${req.params.fixerId} specified`);
|
||||
}
|
||||
|
||||
// Someday!
|
||||
// await runWorker(
|
||||
// new URL('../tasks/fixers/backfillProgramGroupings', import.meta.url),
|
||||
// );
|
||||
|
||||
await fixer.run();
|
||||
|
||||
return res.send();
|
||||
},
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/system/settings',
|
||||
{
|
||||
@@ -231,7 +231,10 @@ export class ProgramConverter {
|
||||
icon: nullToUndefined(program.episodeIcon ?? program.showIcon),
|
||||
showId: nullToUndefined(program.tvShow?.uuid ?? program.tvShowUuid),
|
||||
seasonId: nullToUndefined(program.tvSeason?.uuid ?? program.seasonUuid),
|
||||
seasonNumber: nullToUndefined(program.tvSeason?.index),
|
||||
// Fallback to the denormalized field, for now
|
||||
seasonNumber: nullToUndefined(
|
||||
program.tvSeason?.index, // ?? program.seasonNumber,
|
||||
),
|
||||
episodeNumber: nullToUndefined(program.episode),
|
||||
episodeTitle: program.title,
|
||||
title: nullToUndefined(program.tvShow?.title ?? program.showTitle),
|
||||
|
||||
@@ -44,6 +44,7 @@ export const MinimalProgramGroupingFields: ProgramGroupingFields = [
|
||||
'programGrouping.uuid',
|
||||
'programGrouping.title',
|
||||
'programGrouping.year',
|
||||
// 'programGrouping.index',
|
||||
];
|
||||
|
||||
type FillerShowFields = readonly `fillerShow.${keyof RawFillerShow}`[];
|
||||
|
||||
@@ -3,8 +3,9 @@ import { exec } from 'child_process';
|
||||
import _, { isEmpty, isError, nth, some, trim } from 'lodash-es';
|
||||
import NodeCache from 'node-cache';
|
||||
import PQueue from 'p-queue';
|
||||
import { Nullable } from '../types/util.js';
|
||||
import { cacheGetOrSet } from '../util/cache.js';
|
||||
import { attempt, isNonEmptyString } from '../util/index.js';
|
||||
import { attempt, isNonEmptyString, parseIntOrNull } from '../util/index.js';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory';
|
||||
import { NvidiaHardwareCapabilities } from './NvidiaHardwareCapabilities.js';
|
||||
|
||||
@@ -15,9 +16,18 @@ const CacheKeys = {
|
||||
NVIDIA: 'nvidia',
|
||||
} as const;
|
||||
|
||||
export type FfmpegVersionResult = {
|
||||
versionString: string;
|
||||
majorVersion?: Nullable<number>;
|
||||
minorVersion?: Nullable<number>;
|
||||
patchVersion?: Nullable<number>;
|
||||
versionDetails?: Nullable<string>;
|
||||
};
|
||||
|
||||
const execQueue = new PQueue({ concurrency: 2 });
|
||||
|
||||
const VersionExtractionPattern = /version\s+([^\s]+)\s+.*Copyright/;
|
||||
const VersionNumberExtractionPattern = /n?(\d+)\.(\d+)(\.(\d+))?[_\-.]*(.*)/;
|
||||
const CoderExtractionPattern = /[A-Z.]+\s([a-z0-9_-]+)\s*(.*)$/;
|
||||
const OptionsExtractionPattern = /^-([a-z_]+)\s+.*/;
|
||||
const NvidiaGpuArchPattern = /SM\s+(\d\.\d)/;
|
||||
@@ -39,9 +49,11 @@ export class FFMPEGInfo {
|
||||
}
|
||||
|
||||
private ffmpegPath: string;
|
||||
private ffprobePath: string;
|
||||
|
||||
constructor(opts: FfmpegSettings) {
|
||||
this.ffmpegPath = opts.ffmpegExecutablePath;
|
||||
this.ffprobePath = opts.ffprobeExecutablePath;
|
||||
}
|
||||
|
||||
async seed() {
|
||||
@@ -60,23 +72,65 @@ export class FFMPEGInfo {
|
||||
}
|
||||
}
|
||||
|
||||
async getVersion() {
|
||||
async getVersion(): Promise<FfmpegVersionResult> {
|
||||
try {
|
||||
const s = await this.getFfmpegStdout(['-hide_banner', '-version']);
|
||||
const m = s.match(VersionExtractionPattern);
|
||||
if (!m) {
|
||||
this.logger.warn(
|
||||
'ffmpeg -version command output not in the expected format: ' + s,
|
||||
);
|
||||
return s;
|
||||
}
|
||||
return m[1];
|
||||
const s = await this.getFfmpegStdout(['-version']);
|
||||
return this.parseVersion(s, 'ffmpeg');
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return 'unknown';
|
||||
return { versionString: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
async getFfprobeVersion(): Promise<FfmpegVersionResult> {
|
||||
try {
|
||||
const s = await this.getFfprobeStdout(['-version']);
|
||||
return this.parseVersion(s, 'ffprobe');
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
return { versionString: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
private parseVersion(output: string, app: string) {
|
||||
const m = output.match(VersionExtractionPattern);
|
||||
if (!m) {
|
||||
this.logger.warn(
|
||||
`${app} -version command output not in the expected format: ${output}`,
|
||||
);
|
||||
return { versionString: output };
|
||||
}
|
||||
const versionString = m[1];
|
||||
|
||||
const extractedNums = versionString.match(VersionNumberExtractionPattern);
|
||||
|
||||
if (!extractedNums) {
|
||||
return { versionString };
|
||||
}
|
||||
|
||||
const majorString = nth(extractedNums, 1);
|
||||
const minorString = nth(extractedNums, 2);
|
||||
const patchString = nth(extractedNums, 4);
|
||||
const rest = nth(extractedNums, 5);
|
||||
const majorNum = isNonEmptyString(majorString)
|
||||
? parseIntOrNull(majorString)
|
||||
: null;
|
||||
const minorNum = isNonEmptyString(minorString)
|
||||
? parseIntOrNull(minorString)
|
||||
: null;
|
||||
const patchNum = isNonEmptyString(patchString)
|
||||
? parseIntOrNull(patchString)
|
||||
: null;
|
||||
|
||||
return {
|
||||
versionString,
|
||||
majorVersion: majorNum,
|
||||
minorVersion: minorNum,
|
||||
patchVersion: patchNum,
|
||||
versionDetails: rest,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableAudioEncoders() {
|
||||
return attempt(async () => {
|
||||
const out = await cacheGetOrSet(
|
||||
@@ -225,12 +279,27 @@ export class FFMPEGInfo {
|
||||
private getFfmpegStdout(
|
||||
args: string[],
|
||||
swallowError: boolean = false,
|
||||
): Promise<string> {
|
||||
return this.getStdout(this.ffmpegPath, args, swallowError);
|
||||
}
|
||||
|
||||
private getFfprobeStdout(
|
||||
args: string[],
|
||||
swallowError: boolean = false,
|
||||
): Promise<string> {
|
||||
return this.getStdout(this.ffprobePath, args, swallowError);
|
||||
}
|
||||
|
||||
private getStdout(
|
||||
executable: string,
|
||||
args: string[],
|
||||
swallowError: boolean = false,
|
||||
): Promise<string> {
|
||||
return execQueue.add(
|
||||
async () =>
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(
|
||||
`"${this.ffmpegPath}" ${args.join(' ')}`,
|
||||
`"${executable}" ${args.join(' ')}`,
|
||||
function (error, stdout, stderr) {
|
||||
if (error !== null && !swallowError) {
|
||||
reject(error);
|
||||
|
||||
@@ -41,7 +41,16 @@ import {
|
||||
initializeSingletons,
|
||||
serverOptions,
|
||||
} from './globals.js';
|
||||
import { ServerRequestContext, serverContext } from './serverContext.js';
|
||||
import {
|
||||
ServerContext,
|
||||
ServerRequestContext,
|
||||
serverContext,
|
||||
} from './serverContext.js';
|
||||
import { FfmpegDebugLoggingHealthCheck } from './services/health_checks/FfmpegDebugLoggingHealthCheck.js';
|
||||
import { FfmpegVersionHealthCheck } from './services/health_checks/FfmpegVersionHealthCheck.js';
|
||||
import { HardwareAccelerationHealthCheck } from './services/health_checks/HardwareAccelerationHealthCheck.js';
|
||||
import { MissingProgramAssociationsHealthCheck } from './services/health_checks/MissingProgramAssociationsHealthCheck.js';
|
||||
import { MissingSeasonNumbersHealthCheck } from './services/health_checks/MissingSeasonNumbersHealthCheck.js';
|
||||
import { GlobalScheduler, scheduleJobs } from './services/scheduler.js';
|
||||
import { initPersistentStreamCache } from './stream/ChannelCache.js';
|
||||
import { UpdateXmlTvTask } from './tasks/UpdateXmlTvTask.js';
|
||||
@@ -115,6 +124,22 @@ async function legacyDizquetvDirectoryPath() {
|
||||
return;
|
||||
}
|
||||
|
||||
function registerHealthChecks(ctx: ServerContext) {
|
||||
ctx.healthCheckService.registerCheck(new MissingSeasonNumbersHealthCheck());
|
||||
ctx.healthCheckService.registerCheck(
|
||||
new FfmpegVersionHealthCheck(ctx.settings),
|
||||
);
|
||||
ctx.healthCheckService.registerCheck(
|
||||
new HardwareAccelerationHealthCheck(ctx.settings),
|
||||
);
|
||||
ctx.healthCheckService.registerCheck(
|
||||
new FfmpegDebugLoggingHealthCheck(ctx.settings),
|
||||
);
|
||||
ctx.healthCheckService.registerCheck(
|
||||
new MissingProgramAssociationsHealthCheck(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function initServer(opts: ServerOptions) {
|
||||
const start = performance.now();
|
||||
await initDbDirectories();
|
||||
@@ -131,6 +156,7 @@ export async function initServer(opts: ServerOptions) {
|
||||
initializeSingletons();
|
||||
|
||||
const ctx = serverContext();
|
||||
registerHealthChecks(ctx);
|
||||
await new ChannelLineupMigrator(ctx.channelDB).run();
|
||||
|
||||
const legacyDbPath = await legacyDizquetvDirectoryPath();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProgramDB } from './dao/programDB.js';
|
||||
import { SettingsDB, getSettings } from './dao/settings.js';
|
||||
import { serverOptions } from './globals.js';
|
||||
import { HdhrService } from './hdhr.js';
|
||||
import { HealthCheckService } from './services/HealthCheckService.js';
|
||||
import { OnDemandChannelService } from './services/OnDemandChannelService.js';
|
||||
import { CacheImageService } from './services/cacheImageService.js';
|
||||
import { EventService } from './services/eventService.js';
|
||||
@@ -25,6 +26,7 @@ export class ServerContext {
|
||||
public readonly programConverter = new ProgramConverter();
|
||||
public readonly sessionManager: SessionManager;
|
||||
public readonly onDemandChannelService: OnDemandChannelService;
|
||||
public readonly healthCheckService: HealthCheckService;
|
||||
|
||||
constructor(
|
||||
public channelDB: ChannelDB,
|
||||
@@ -46,6 +48,7 @@ export class ServerContext {
|
||||
this.channelDB,
|
||||
this.onDemandChannelService,
|
||||
);
|
||||
this.healthCheckService = new HealthCheckService();
|
||||
}
|
||||
|
||||
streamProgramCalculator() {
|
||||
|
||||
63
server/src/services/HealthCheckService.ts
Normal file
63
server/src/services/HealthCheckService.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { difference, keys, map, reduce, values } from 'lodash-es';
|
||||
import { mapToObj } from '../util';
|
||||
import { LoggerFactory } from '../util/logging/LoggerFactory';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
healthCheckResult,
|
||||
} from './health_checks/HealthCheck';
|
||||
|
||||
export class HealthCheckService {
|
||||
#logger = LoggerFactory.child({ className: this.constructor.name });
|
||||
#checks: Record<string, HealthCheck> = {};
|
||||
|
||||
registerCheck(check: HealthCheck) {
|
||||
if (this.#checks[check.id]) {
|
||||
this.#logger.debug('Duplicate health check registration. Overwriting.');
|
||||
}
|
||||
|
||||
this.#checks[check.id] = check;
|
||||
}
|
||||
|
||||
async runAll() {
|
||||
const allResults = await Promise.allSettled(
|
||||
map(values(this.#checks), async (check) => {
|
||||
const result = await check.getStatus();
|
||||
return [check.id, result] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
const nonErrorResults = reduce(
|
||||
allResults,
|
||||
(prev, cur) => {
|
||||
switch (cur.status) {
|
||||
case 'rejected':
|
||||
break;
|
||||
case 'fulfilled': {
|
||||
const [id, result] = cur.value;
|
||||
prev[id] = result;
|
||||
}
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
{} as Record<string, HealthCheckResult>,
|
||||
);
|
||||
|
||||
// Any checks that failed to run are treated as errors. They should've logged themselves!
|
||||
const missingKeys: Record<string, HealthCheckResult> = mapToObj(
|
||||
difference(keys(this.#checks), keys(nonErrorResults)),
|
||||
(key) =>
|
||||
({
|
||||
[key]: healthCheckResult({
|
||||
type: 'error',
|
||||
context: 'Health check failed to run. Check the server logs.',
|
||||
}),
|
||||
}) as const,
|
||||
);
|
||||
|
||||
return {
|
||||
...nonErrorResults,
|
||||
...missingKeys,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FfmpegNumericLogLevels } from '@tunarr/types/schemas';
|
||||
import { SettingsDB, getSettings } from '../../dao/settings';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthyHealthCheckResult,
|
||||
healthCheckResult,
|
||||
} from './HealthCheck';
|
||||
|
||||
export class FfmpegDebugLoggingHealthCheck implements HealthCheck {
|
||||
readonly id: string = 'FfmpegDebugLogging';
|
||||
|
||||
constructor(private settingsDB: SettingsDB = getSettings()) {}
|
||||
|
||||
getStatus(): Promise<HealthCheckResult> {
|
||||
const settings = this.settingsDB.ffmpegSettings();
|
||||
|
||||
if (
|
||||
settings.enableLogging &&
|
||||
FfmpegNumericLogLevels[settings.logLevel] >
|
||||
FfmpegNumericLogLevels['warning']
|
||||
) {
|
||||
return Promise.resolve(
|
||||
healthCheckResult({
|
||||
type: 'warning',
|
||||
context:
|
||||
'ffmpeg logging to console is enabled at a granularity finer than "warning", which can affect ffmpeg performance.',
|
||||
}),
|
||||
);
|
||||
} else if (settings.enableFileLogging) {
|
||||
return Promise.resolve(
|
||||
healthCheckResult({
|
||||
type: 'warning',
|
||||
context:
|
||||
'ffmpeg report logging is enabled which could use a lot of disk space.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(HealthyHealthCheckResult);
|
||||
}
|
||||
}
|
||||
127
server/src/services/health_checks/FfmpegVersionHealthCheck.ts
Normal file
127
server/src/services/health_checks/FfmpegVersionHealthCheck.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { every, isNil, some } from 'lodash-es';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { SettingsDB, getSettings } from '../../dao/settings';
|
||||
import { FFMPEGInfo, FfmpegVersionResult } from '../../ffmpeg/ffmpegInfo';
|
||||
import { fileExists } from '../../util/fsUtil';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthyHealthCheckResult,
|
||||
healthCheckResult,
|
||||
} from './HealthCheck';
|
||||
|
||||
export class FfmpegVersionHealthCheck implements HealthCheck {
|
||||
readonly id: string = 'FfmpegVersion';
|
||||
|
||||
private static minVersion = '6.1';
|
||||
|
||||
constructor(private settingsDB: SettingsDB = getSettings()) {}
|
||||
|
||||
async getStatus(): Promise<HealthCheckResult> {
|
||||
const settings = this.settingsDB.ffmpegSettings();
|
||||
|
||||
const ffmpegExists = await fileExists(settings.ffmpegExecutablePath);
|
||||
const ffprobeExists = await fileExists(settings.ffprobeExecutablePath);
|
||||
|
||||
console.log(ffmpegExists, ffprobeExists);
|
||||
|
||||
const warningResult = match([ffmpegExists, ffprobeExists] as const)
|
||||
.with([false, true], () =>
|
||||
healthCheckResult({
|
||||
type: 'error',
|
||||
context: `ffmpeg doesn't exist at configured path ${settings.ffmpegExecutablePath}. Tunarr requires ffmpeg to function. The path can be configured in Settings > FFMPEG`,
|
||||
}),
|
||||
)
|
||||
.with([true, false], () =>
|
||||
healthCheckResult({
|
||||
type: 'error',
|
||||
context: `ffprobe doesn't exist at configured path ${settings.ffprobeExecutablePath}. Tunarr requires ffprobe to function. The path can be configured in Settings > FFMPEG`,
|
||||
}),
|
||||
)
|
||||
.with([false, false], () =>
|
||||
healthCheckResult({
|
||||
type: 'error',
|
||||
context: `Neither ffmpeg nor ffprobe exists at configured paths (ffmpeg=${settings.ffmpegExecutablePath}, ffprobe=${settings.ffprobeExecutablePath}). Tunarr requires both programs to function. The paths can be configured in Settings > FFMPEG`,
|
||||
}),
|
||||
)
|
||||
.otherwise(() => null);
|
||||
|
||||
if (warningResult) {
|
||||
return warningResult;
|
||||
}
|
||||
|
||||
const info = new FFMPEGInfo(settings);
|
||||
const version = await info.getVersion();
|
||||
const ffmpegVersionError = this.isVersionValid(version, 'ffmpeg');
|
||||
if (ffmpegVersionError) {
|
||||
return ffmpegVersionError;
|
||||
}
|
||||
|
||||
const ffprobeVersionError = this.isVersionValid(
|
||||
await info.getFfprobeVersion(),
|
||||
'ffprobe',
|
||||
);
|
||||
if (ffprobeVersionError) {
|
||||
return ffprobeVersionError;
|
||||
}
|
||||
|
||||
return HealthyHealthCheckResult;
|
||||
}
|
||||
|
||||
private isVersionValid(version: FfmpegVersionResult, app: string) {
|
||||
const versionString = version.versionString;
|
||||
|
||||
console.log(version);
|
||||
// Try to use the parsed major/minor versions first
|
||||
if (!isNil(version.majorVersion) && !isNil(version.minorVersion)) {
|
||||
const result = match([version.majorVersion, version.minorVersion])
|
||||
.with(
|
||||
[P.number.lt(6), P._],
|
||||
() =>
|
||||
`${app} version ${versionString} is too old. Please install at least version ${FfmpegVersionHealthCheck.minVersion} of ${app}`,
|
||||
)
|
||||
.with(
|
||||
[6, 0],
|
||||
() =>
|
||||
`${app} version ${versionString} is too old. Please install at least version ${FfmpegVersionHealthCheck.minVersion} of ${app}`,
|
||||
)
|
||||
.otherwise(() => null);
|
||||
if (result) {
|
||||
return healthCheckResult({ context: result, type: 'error' });
|
||||
} else {
|
||||
return HealthyHealthCheckResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
some(
|
||||
['3.', '4.', '5.'],
|
||||
(prefix) =>
|
||||
versionString.startsWith(prefix) ||
|
||||
versionString.startsWith(`n${prefix}`),
|
||||
)
|
||||
) {
|
||||
return healthCheckResult({
|
||||
type: 'error',
|
||||
context: `${app} version ${versionString} is too old. Please install at least version ${FfmpegVersionHealthCheck.minVersion} of ${app}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
every(
|
||||
[
|
||||
FfmpegVersionHealthCheck.minVersion,
|
||||
`n${FfmpegVersionHealthCheck.minVersion}`,
|
||||
],
|
||||
(prefix) => !versionString.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
return healthCheckResult({
|
||||
type: 'warning',
|
||||
context: `${app} version ${versionString} is unrecognized and may have issues.`,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SupportedHardwareAccels } from '@tunarr/types/schemas';
|
||||
import { intersection, isEmpty, reject } from 'lodash-es';
|
||||
import { SettingsDB, getSettings } from '../../dao/settings';
|
||||
import { FFMPEGInfo } from '../../ffmpeg/ffmpegInfo';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthyHealthCheckResult,
|
||||
healthCheckResult,
|
||||
} from './HealthCheck';
|
||||
|
||||
export class HardwareAccelerationHealthCheck implements HealthCheck {
|
||||
readonly id: string = 'HardwareAcceleration';
|
||||
|
||||
constructor(private settings: SettingsDB = getSettings()) {}
|
||||
|
||||
async getStatus(): Promise<HealthCheckResult> {
|
||||
const supported = reject(SupportedHardwareAccels, (hw) => hw === 'none');
|
||||
const info = new FFMPEGInfo(this.settings.ffmpegSettings());
|
||||
const hwAccels = await info.getHwAccels();
|
||||
|
||||
if (intersection(supported, hwAccels).length === 0) {
|
||||
return healthCheckResult({
|
||||
type: 'info',
|
||||
context: `No compatible hardware acceleration modes were found in the configured ffmpeg. (Supported modes = [${supported.join(
|
||||
', ',
|
||||
)}], found modes = ${
|
||||
isEmpty(hwAccels) ? 'NONE' : hwAccels.join(', ')
|
||||
})`,
|
||||
});
|
||||
}
|
||||
return HealthyHealthCheckResult;
|
||||
}
|
||||
}
|
||||
21
server/src/services/health_checks/HealthCheck.ts
Normal file
21
server/src/services/health_checks/HealthCheck.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type HealthyCheckResult = {
|
||||
type: 'healthy';
|
||||
};
|
||||
|
||||
export const HealthyHealthCheckResult: HealthyCheckResult = { type: 'healthy' };
|
||||
|
||||
export type NonHealthyCheckResult = {
|
||||
type: 'info' | 'warning' | 'error';
|
||||
context: string;
|
||||
};
|
||||
|
||||
export type HealthCheckResult = HealthyCheckResult | NonHealthyCheckResult;
|
||||
|
||||
export function healthCheckResult(result: HealthCheckResult): typeof result {
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
getStatus(): Promise<HealthCheckResult>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { find } from 'lodash-es';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { directDbAccess } from '../../dao/direct/directDbAccess';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthyHealthCheckResult,
|
||||
healthCheckResult,
|
||||
} from './HealthCheck';
|
||||
|
||||
export class MissingProgramAssociationsHealthCheck implements HealthCheck {
|
||||
readonly id: string = this.constructor.name;
|
||||
|
||||
async getStatus(): Promise<HealthCheckResult> {
|
||||
const missingParents = await directDbAccess()
|
||||
.selectFrom('program')
|
||||
.select((eb) => ['type', eb.fn.count<number>('uuid').as('count')])
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb('type', '=', 'episode'),
|
||||
eb.or([eb('tvShowUuid', 'is', null), eb('seasonUuid', 'is', null)]),
|
||||
]),
|
||||
eb.and([
|
||||
eb('type', '=', 'track'),
|
||||
eb.or([eb('albumUuid', 'is', null), eb('artistUuid', 'is', null)]),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.groupBy('type')
|
||||
.$narrowType<{ type: 'episode' | 'track' }>()
|
||||
.execute();
|
||||
|
||||
const missingEpisodeAssociations =
|
||||
find(missingParents, { type: 'episode' })?.count ?? 0;
|
||||
const missingTrackAssociations =
|
||||
find(missingParents, { type: 'track' })?.count ?? 0;
|
||||
|
||||
return match([
|
||||
missingEpisodeAssociations,
|
||||
missingTrackAssociations,
|
||||
] as const)
|
||||
.with([0, P.number.gt(0)], () =>
|
||||
healthCheckResult({
|
||||
type: 'warning',
|
||||
context: `There were ${missingTrackAssociations} audio track(s) missing parent associations in the DB. This can lead to a broken XMLTV/Guide`,
|
||||
}),
|
||||
)
|
||||
.with([P.number.gt(0), 0], () =>
|
||||
healthCheckResult({
|
||||
type: 'warning',
|
||||
context: `There were ${missingEpisodeAssociations} episode(s) missing parent associations in the DB. This can lead to a broken XMLTV/Guide`,
|
||||
}),
|
||||
)
|
||||
.with([P.number.gt(0), P.number.gt(0)], () =>
|
||||
healthCheckResult({
|
||||
type: 'warning',
|
||||
context: `There were ${missingEpisodeAssociations} episode(s) and ${missingTrackAssociations} audio track(s) missing parent associations in the DB. This can lead to a broken XMLTV/Guide`,
|
||||
}),
|
||||
)
|
||||
.otherwise(() => HealthyHealthCheckResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { directDbAccess } from '../../dao/direct/directDbAccess';
|
||||
import { ProgramType } from '../../dao/entities/Program';
|
||||
import { ProgramGroupingType } from '../../dao/entities/ProgramGrouping';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthyHealthCheckResult,
|
||||
} from './HealthCheck';
|
||||
|
||||
export class MissingSeasonNumbersHealthCheck implements HealthCheck {
|
||||
readonly id = 'MissingSeasonNumbers';
|
||||
|
||||
async getStatus(): Promise<HealthCheckResult> {
|
||||
const missingFromProgramTable = await directDbAccess()
|
||||
.selectFrom('program')
|
||||
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
||||
.where('type', '=', ProgramType.Episode)
|
||||
.where((eb) => eb.or([eb('seasonNumber', 'is', null)]))
|
||||
.executeTakeFirst();
|
||||
|
||||
const missingFromGroupingTable = await directDbAccess()
|
||||
.selectFrom('programGrouping')
|
||||
.select((eb) => eb.fn.count<number>('uuid').as('count'))
|
||||
.where('type', '=', ProgramGroupingType.TvShowSeason)
|
||||
.where('index', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
const totalMissing =
|
||||
(missingFromProgramTable?.count ?? 0) +
|
||||
(missingFromGroupingTable?.count ?? 0);
|
||||
|
||||
if (totalMissing === 0) {
|
||||
return HealthyHealthCheckResult;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'warning',
|
||||
context: `There are ${totalMissing} program(s) missing a season number`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { NotNull } from 'kysely';
|
||||
import { chunk, head, reduce, tail } from 'lodash-es';
|
||||
import { ProgramSourceType } from '../../dao/custom_types/ProgramSourceType';
|
||||
import { directDbAccess } from '../../dao/direct/directDbAccess.js';
|
||||
import { ProgramType } from '../../dao/entities/Program';
|
||||
import { ProgramGroupingType } from '../../dao/entities/ProgramGrouping';
|
||||
import { LoggerFactory } from '../../util/logging/LoggerFactory';
|
||||
import { Timer } from '../../util/perf';
|
||||
import Fixer from './fixer';
|
||||
|
||||
// TODO: Handle Jellyfin items
|
||||
@@ -15,57 +11,41 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
caller: import.meta,
|
||||
className: BackfillProgramGroupings.name,
|
||||
});
|
||||
private timer = new Timer(this.logger);
|
||||
|
||||
protected async runInternal(): Promise<void> {
|
||||
// We'll try filling using the data we have first...
|
||||
const results = await this.timer.timeAsync(
|
||||
'missing groupiings db query',
|
||||
() =>
|
||||
directDbAccess()
|
||||
.selectFrom('program')
|
||||
.select(['program.uuid', 'program.tvShowUuid'])
|
||||
.where('program.seasonUuid', 'is not', null)
|
||||
.where('program.tvShowUuid', 'is not', null)
|
||||
.innerJoin('programGrouping', (join) =>
|
||||
join
|
||||
.onRef('programGrouping.uuid', '=', 'program.seasonUuid')
|
||||
.on('programGrouping.showUuid', 'is', null),
|
||||
)
|
||||
.select('programGrouping.uuid as seasonId')
|
||||
.groupBy(['program.seasonUuid', 'program.tvShowUuid'])
|
||||
.$narrowType<{ tvShowUuid: NotNull }>()
|
||||
.execute(),
|
||||
);
|
||||
// This clears out mismatches that might have happened on bugged earlier versions
|
||||
// There was a bug where we were setting the season ID to the show ID.
|
||||
// This should only affect seasons since the music album stuff had the fix
|
||||
const clearedSeasons = await directDbAccess()
|
||||
.transaction()
|
||||
.execute((tx) =>
|
||||
tx
|
||||
.updateTable('program')
|
||||
.set(({ selectFrom, eb }) => ({
|
||||
seasonUuid: eb
|
||||
.case()
|
||||
.when(
|
||||
selectFrom('programGrouping')
|
||||
.whereRef('programGrouping.uuid', '=', 'program.seasonUuid')
|
||||
.where(
|
||||
'programGrouping.type',
|
||||
'=',
|
||||
ProgramGroupingType.TvShow,
|
||||
)
|
||||
.select((eb) => eb.lit(1).as('true'))
|
||||
.limit(1),
|
||||
)
|
||||
.then(null)
|
||||
.else(eb.ref('seasonUuid'))
|
||||
.end(),
|
||||
}))
|
||||
.executeTakeFirst(),
|
||||
);
|
||||
|
||||
await this.timer.timeAsync('update program groupings 1', async () => {
|
||||
for (const result of chunk(results, 50)) {
|
||||
const first = head(result)!;
|
||||
const rest = tail(result);
|
||||
await directDbAccess()
|
||||
.transaction()
|
||||
.execute((tx) =>
|
||||
tx
|
||||
.updateTable('programGrouping')
|
||||
.set(({ eb }) => {
|
||||
return {
|
||||
showUuid: reduce(
|
||||
rest,
|
||||
(ebb, r) =>
|
||||
ebb
|
||||
.when('programGrouping.uuid', '=', r.seasonId)
|
||||
.then(r.tvShowUuid),
|
||||
eb
|
||||
.case()
|
||||
.when('programGrouping.uuid', '=', first.seasonId)
|
||||
.then(first.tvShowUuid),
|
||||
).end(),
|
||||
};
|
||||
})
|
||||
.executeTakeFirst(),
|
||||
);
|
||||
}
|
||||
});
|
||||
this.logger.debug(
|
||||
'Cleared %s bugged seasons',
|
||||
clearedSeasons.numChangedRows ?? clearedSeasons.numUpdatedRows ?? 0n,
|
||||
);
|
||||
|
||||
// Update program -> show mappings with existing information
|
||||
const updatedShows = await directDbAccess()
|
||||
@@ -85,6 +65,12 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
'=',
|
||||
'program.grandparentExternalKey',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
'programGrouping',
|
||||
'programGroupingExternalId.groupUuid',
|
||||
@@ -98,7 +84,6 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
eb('program.type', '=', ProgramType.Episode),
|
||||
eb('program.grandparentExternalKey', 'is not', null),
|
||||
eb('program.tvShowUuid', 'is', null),
|
||||
eb('program.sourceType', '=', ProgramSourceType.PLEX),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst(),
|
||||
@@ -106,11 +91,57 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s program->show mappings',
|
||||
updatedShows.numUpdatedRows,
|
||||
updatedShows.numChangedRows ?? 0n,
|
||||
);
|
||||
|
||||
// Update track -> artist mappings with existing information
|
||||
const updatedTrackArtists = await directDbAccess()
|
||||
.transaction()
|
||||
.execute((tx) =>
|
||||
tx
|
||||
.updateTable('program')
|
||||
.set(({ selectFrom }) => ({
|
||||
artistUuid: selectFrom('programGroupingExternalId')
|
||||
.whereRef(
|
||||
'programGroupingExternalId.externalSourceId',
|
||||
'=',
|
||||
'program.externalSourceId',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.externalKey',
|
||||
'=',
|
||||
'program.grandparentExternalKey',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
'programGrouping',
|
||||
'programGroupingExternalId.groupUuid',
|
||||
'programGrouping.uuid',
|
||||
)
|
||||
.select('programGrouping.uuid')
|
||||
.limit(1),
|
||||
}))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('program.type', '=', ProgramType.Track),
|
||||
eb('program.grandparentExternalKey', 'is not', null),
|
||||
eb('program.artistUuid', 'is', null),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst(),
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s track->artist mappings',
|
||||
updatedTrackArtists.numChangedRows ?? 0n,
|
||||
);
|
||||
|
||||
// Update show -> season mappings with existing information
|
||||
// Do this in the background since it is less important.
|
||||
await directDbAccess()
|
||||
.transaction()
|
||||
.execute(async (tx) => {
|
||||
@@ -126,8 +157,14 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
.whereRef(
|
||||
'programGroupingExternalId.externalKey',
|
||||
'=',
|
||||
'program.grandparentExternalKey',
|
||||
'program.parentExternalKey',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
'programGrouping',
|
||||
'programGroupingExternalId.groupUuid',
|
||||
@@ -141,14 +178,13 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
eb('program.type', '=', ProgramType.Episode),
|
||||
eb('program.parentExternalKey', 'is not', null),
|
||||
eb('program.seasonUuid', 'is', null),
|
||||
eb('program.sourceType', '=', ProgramSourceType.PLEX),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s program->season mappings',
|
||||
updatedSeasons.numUpdatedRows,
|
||||
updatedSeasons.numChangedRows ?? 0n,
|
||||
);
|
||||
|
||||
const res = await tx
|
||||
@@ -164,6 +200,11 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
'=',
|
||||
'program.externalSourceId',
|
||||
)
|
||||
.onRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
.onRef(
|
||||
'programGroupingExternalId.externalKey',
|
||||
'=',
|
||||
@@ -182,9 +223,98 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
.where('programGrouping.type', '=', ProgramGroupingType.TvShowSeason)
|
||||
.where('programGrouping.showUuid', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s show->season associations',
|
||||
res.numUpdatedRows,
|
||||
res.numChangedRows ?? 0n,
|
||||
);
|
||||
});
|
||||
|
||||
// Update track -> album mappings with existing information
|
||||
await directDbAccess()
|
||||
.transaction()
|
||||
.execute(async (tx) => {
|
||||
const updatedTracks = await tx
|
||||
.updateTable('program')
|
||||
.set(({ selectFrom }) => ({
|
||||
albumUuid: selectFrom('programGroupingExternalId')
|
||||
.whereRef(
|
||||
'programGroupingExternalId.externalSourceId',
|
||||
'=',
|
||||
'program.externalSourceId',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.externalKey',
|
||||
'=',
|
||||
'program.parentExternalKey',
|
||||
)
|
||||
.whereRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
.leftJoin(
|
||||
'programGrouping',
|
||||
'programGroupingExternalId.groupUuid',
|
||||
'programGrouping.uuid',
|
||||
)
|
||||
.select('programGrouping.uuid')
|
||||
.limit(1),
|
||||
}))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
eb('program.type', '=', ProgramType.Track),
|
||||
eb('program.parentExternalKey', 'is not', null),
|
||||
eb('program.albumUuid', 'is', null),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s track->album mappings',
|
||||
updatedTracks.numChangedRows ?? 0n,
|
||||
);
|
||||
|
||||
const res = await tx
|
||||
.updateTable('programGrouping')
|
||||
.set(({ selectFrom }) => ({
|
||||
artistUuid: selectFrom('program')
|
||||
.where('program.type', '=', ProgramType.Track)
|
||||
.where('program.grandparentExternalKey', 'is not', null)
|
||||
.innerJoin('programGroupingExternalId', (join) =>
|
||||
join
|
||||
.onRef(
|
||||
'programGroupingExternalId.externalSourceId',
|
||||
'=',
|
||||
'program.externalSourceId',
|
||||
)
|
||||
.onRef(
|
||||
'programGroupingExternalId.sourceType',
|
||||
'=',
|
||||
'program.sourceType',
|
||||
)
|
||||
.onRef(
|
||||
'programGroupingExternalId.externalKey',
|
||||
'=',
|
||||
'program.grandparentExternalKey',
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
'programGrouping',
|
||||
'programGrouping.uuid',
|
||||
'programGroupingExternalId.groupUuid',
|
||||
)
|
||||
.where('programGrouping.uuid', 'is not', null)
|
||||
.select('programGrouping.uuid')
|
||||
.limit(1),
|
||||
}))
|
||||
.where('programGrouping.type', '=', ProgramGroupingType.MusicAlbum)
|
||||
.where('programGrouping.artistUuid', 'is', null)
|
||||
.executeTakeFirst();
|
||||
|
||||
this.logger.debug(
|
||||
'Fixed %s album->artist associations',
|
||||
res.numChangedRows ?? 0n,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -192,13 +322,21 @@ export class BackfillProgramGroupings extends Fixer {
|
||||
.selectFrom('program')
|
||||
.select(({ fn }) => fn.count<number>('program.uuid').as('count'))
|
||||
.where((eb) =>
|
||||
eb.or([eb('tvShowUuid', 'is', null), eb('seasonUuid', 'is', null)]),
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb('type', '=', ProgramType.Episode),
|
||||
eb.or([eb('tvShowUuid', 'is', null), eb('seasonUuid', 'is', null)]),
|
||||
]),
|
||||
eb.and([
|
||||
eb('type', '=', ProgramType.Track),
|
||||
eb.or([eb('albumUuid', 'is', null), eb('artistUuid', 'is', null)]),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.where('type', '=', ProgramType.Episode)
|
||||
.executeTakeFirst();
|
||||
|
||||
this.logger.debug(
|
||||
'There are still %d episode programs with missing associations',
|
||||
'There are still %d programs with missing associations',
|
||||
stillMissing?.count,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,3 +488,8 @@ export function nullToUndefined<T>(x: T | null | undefined): T | undefined {
|
||||
export function removeErrors<T>(coll: Try<T>[] | null | undefined): T[] {
|
||||
return reject(coll, isError) satisfies T[] as T[];
|
||||
}
|
||||
|
||||
export function parseIntOrNull(s: string): number | null {
|
||||
const parsed = parseInt(s);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cpus } from 'os';
|
||||
import PQueue from 'p-queue';
|
||||
import { Worker } from 'worker_threads';
|
||||
import { SHARE_ENV, Worker } from 'worker_threads';
|
||||
|
||||
await import('tsx/esm');
|
||||
|
||||
const queue = new PQueue({ concurrency: cpus().length });
|
||||
|
||||
@@ -10,11 +12,14 @@ export function runWorker<T>(
|
||||
): Promise<T> {
|
||||
return queue.add<T>(
|
||||
async () => {
|
||||
console.log(filenameWithoutExtension);
|
||||
const worker =
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? new Worker(new URL(`${filenameWithoutExtension.toString()}.ts`), {
|
||||
? new Worker(new URL(import.meta.resolve('tsx/cli')), {
|
||||
workerData,
|
||||
execArgv: ['--loader', 'ts-node/esm/transpile-only'],
|
||||
// execArgv: ['--import', 'tsx/esm'],
|
||||
env: SHARE_ENV,
|
||||
argv: [`${filenameWithoutExtension.pathname}.ts`],
|
||||
})
|
||||
: new Worker(new URL(`${filenameWithoutExtension.toString()}.js`), {
|
||||
workerData,
|
||||
|
||||
@@ -207,6 +207,12 @@ export function Root({ children }: { children?: React.ReactNode }) {
|
||||
visible: showWelcome,
|
||||
icon: <Home />,
|
||||
},
|
||||
{
|
||||
name: 'Home',
|
||||
path: '/',
|
||||
visible: !showWelcome,
|
||||
icon: <Home />,
|
||||
},
|
||||
{ name: 'Guide', path: '/guide', visible: true, icon: <TvIcon /> },
|
||||
{
|
||||
name: 'Channels',
|
||||
@@ -321,7 +327,7 @@ export function Root({ children }: { children?: React.ReactNode }) {
|
||||
<Link
|
||||
underline="none"
|
||||
color="inherit"
|
||||
to="/guide"
|
||||
to="/"
|
||||
component={RouterLink}
|
||||
>
|
||||
Tunarr
|
||||
@@ -518,7 +524,7 @@ export function Root({ children }: { children?: React.ReactNode }) {
|
||||
>
|
||||
<Toolbar />
|
||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||
{version?.ffmpeg === 'Error' ? (
|
||||
{version?.ffmpeg === 'unknown' ? (
|
||||
<Alert
|
||||
variant="filled"
|
||||
severity="error"
|
||||
|
||||
41
web/src/external/settingsApi.ts
vendored
41
web/src/external/settingsApi.ts
vendored
@@ -140,6 +140,44 @@ const jellyfinLogin = makeEndpoint({
|
||||
response: z.object({ accessToken: z.string().optional() }),
|
||||
});
|
||||
|
||||
const systemHealthChecks = makeEndpoint({
|
||||
method: 'get',
|
||||
path: '/api/system/health',
|
||||
alias: 'getSystemHealth',
|
||||
response: z.record(
|
||||
z.union([
|
||||
z.object({ type: z.literal('healthy') }),
|
||||
z.object({
|
||||
type: z.union([
|
||||
z.literal('info'),
|
||||
z.literal('warning'),
|
||||
z.literal('error'),
|
||||
]),
|
||||
context: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
||||
const runSystemFixer = makeEndpoint({
|
||||
method: 'post',
|
||||
path: '/api/system/fixers/:fixerId/run',
|
||||
alias: 'runSystemFixer',
|
||||
parameters: parametersBuilder()
|
||||
.addParameter('fixerId', 'Path', z.string())
|
||||
.build(),
|
||||
response: z.any(),
|
||||
});
|
||||
|
||||
const systemMigrationState = makeEndpoint({
|
||||
method: 'get',
|
||||
path: '/api/system/state',
|
||||
alias: 'getSystemState',
|
||||
response: z.object({
|
||||
isFreshSettings: z.boolean().optional().default(true),
|
||||
}),
|
||||
});
|
||||
|
||||
export const endpoints = [
|
||||
getMediaSourcesEndpoint,
|
||||
createMediaSourceEndpoint,
|
||||
@@ -156,4 +194,7 @@ export const endpoints = [
|
||||
getSystemSettings,
|
||||
updateSystemSettings,
|
||||
jellyfinLogin,
|
||||
systemHealthChecks,
|
||||
runSystemFixer,
|
||||
systemMigrationState,
|
||||
] as const;
|
||||
|
||||
@@ -30,15 +30,7 @@ export function useApiQuery<
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: Omit<
|
||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryFn'
|
||||
> & {
|
||||
queryFn: (
|
||||
apiClient: ApiClient,
|
||||
...rest: Parameters<QueryFunction<TQueryFnData, TQueryKey, never>>
|
||||
) => ReturnType<QueryFunction<TQueryFnData, TQueryKey, never>>;
|
||||
},
|
||||
options: ApiQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> {
|
||||
// NOTE that this query also depends on the backendUrl used to
|
||||
|
||||
6
web/src/hooks/useM3ULink.ts
Normal file
6
web/src/hooks/useM3ULink.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useBackendUrl } from '@/store/settings/selectors';
|
||||
|
||||
export const useM3ULink = () => {
|
||||
const backendUrl = useBackendUrl();
|
||||
return `${backendUrl}/api/channels.m3u`;
|
||||
};
|
||||
8
web/src/hooks/useSystemHealthChecks.ts
Normal file
8
web/src/hooks/useSystemHealthChecks.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useApiQuery } from './useApiQuery';
|
||||
|
||||
export const useSystemHealthChecks = () => {
|
||||
return useApiQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: (api) => api.getSystemHealth(),
|
||||
});
|
||||
};
|
||||
7
web/src/hooks/useXmlTvLink.ts
Normal file
7
web/src/hooks/useXmlTvLink.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useBackendUrl } from '@/store/settings/selectors';
|
||||
import { trimEnd } from 'lodash-es';
|
||||
|
||||
export const useXmlTvLink = () => {
|
||||
const backendUri = useBackendUrl();
|
||||
return `${trimEnd(backendUri.trim(), '/')}/api/xmltv.xml`;
|
||||
};
|
||||
298
web/src/pages/StatusPage.tsx
Normal file
298
web/src/pages/StatusPage.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { RotatingLoopIcon } from '@/components/base/LoadingIcon';
|
||||
import PaddedPaper from '@/components/base/PaddedPaper';
|
||||
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard';
|
||||
import { useM3ULink } from '@/hooks/useM3ULink';
|
||||
import { useSystemHealthChecks } from '@/hooks/useSystemHealthChecks';
|
||||
import { useSystemSettings } from '@/hooks/useSystemSettings';
|
||||
import { useTunarrApi } from '@/hooks/useTunarrApi';
|
||||
import { useXmlTvLink } from '@/hooks/useXmlTvLink';
|
||||
import { useSettings } from '@/store/settings/selectors';
|
||||
import {
|
||||
CheckCircle,
|
||||
ContentCopy,
|
||||
Error,
|
||||
Info,
|
||||
QuestionMark,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
IconButtonProps,
|
||||
LinearProgress,
|
||||
Link,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { compact, isEmpty, map, reject } from 'lodash-es';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
const MissingSeasonNumbersCheck = 'MissingSeasonNumbers';
|
||||
const FfmpegVersionCheck = 'FfmpegVersion';
|
||||
const HardwareAccelerationCheck = 'HardwareAcceleration';
|
||||
const FfmpegDebugLoggingCheck = 'FfmpegDebugLogging';
|
||||
const MissingProgramAssociationsHealthCheck =
|
||||
'MissingProgramAssociationsHealthCheck';
|
||||
|
||||
const AllKnownChecks = [
|
||||
FfmpegVersionCheck,
|
||||
HardwareAccelerationCheck,
|
||||
FfmpegDebugLoggingCheck,
|
||||
MissingSeasonNumbersCheck,
|
||||
MissingProgramAssociationsHealthCheck,
|
||||
] as const;
|
||||
|
||||
const CopyToClipboardButton = (
|
||||
props: IconButtonProps & { content: string },
|
||||
) => {
|
||||
const copyToClipboard = useCopyToClipboard();
|
||||
|
||||
const handleClick = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
copyToClipboard(props.content).catch(console.warn);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disableRipple
|
||||
sx={{ ml: 0.5, cursor: 'pointer', p: 0 }}
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<ContentCopy sx={{ fontSize: 'inherit' }} />
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
type RunFixerArgs = {
|
||||
fixerId: string;
|
||||
};
|
||||
|
||||
export const StatusPage = () => {
|
||||
const { backendUri } = useSettings();
|
||||
const systemSettings = useSystemSettings();
|
||||
const systemHealthQuery = useSystemHealthChecks();
|
||||
const xmlTvLink = useXmlTvLink();
|
||||
const m3uLink = useM3ULink();
|
||||
|
||||
const [runningFixers, setRunningFixers] = useState<Set<string>>(new Set());
|
||||
const apiClient = useTunarrApi();
|
||||
const queryClient = useQueryClient();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const runSystemFixer = useMutation({
|
||||
mutationFn: ({ fixerId }: RunFixerArgs) =>
|
||||
apiClient.runSystemFixer(undefined, { params: { fixerId } }),
|
||||
onSuccess: async (_, { fixerId }) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['system', 'health'] });
|
||||
snackbar.enqueueSnackbar(`Successfully ran system fixer ${fixerId}`, {
|
||||
variant: 'success',
|
||||
});
|
||||
},
|
||||
onError: (err, { fixerId }) => {
|
||||
console.error(err);
|
||||
snackbar.enqueueSnackbar(
|
||||
`Error while running system fixer ${fixerId}. Check server logs for details.`,
|
||||
{ variant: 'error' },
|
||||
);
|
||||
},
|
||||
onSettled: (_data, _error, { fixerId }) => {
|
||||
setRunningFixers(
|
||||
(prev) => new Set(reject([...prev], (n) => n === fixerId)),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const renderHealthCheckResults = () => {
|
||||
const checkRows = compact(
|
||||
map(AllKnownChecks, (check) => {
|
||||
const prettyName = match(check)
|
||||
.with(MissingSeasonNumbersCheck, () => 'Missing Season Numbers')
|
||||
.with(FfmpegVersionCheck, () => 'FFmpeg Version')
|
||||
.with(HardwareAccelerationCheck, () => 'Hardware Acceleration')
|
||||
.with(FfmpegDebugLoggingCheck, () => 'FFmpeg Report Logging')
|
||||
.with(
|
||||
MissingProgramAssociationsHealthCheck,
|
||||
() => 'Missing Program Associations',
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
const fixer = match(check)
|
||||
.with(MissingSeasonNumbersCheck, () => 'MissingSeasonNumbersFixer')
|
||||
.with(
|
||||
MissingProgramAssociationsHealthCheck,
|
||||
() => 'BackfillProgramGroupings',
|
||||
)
|
||||
.otherwise(() => null);
|
||||
|
||||
const data = systemHealthQuery.data?.[check];
|
||||
|
||||
const icon =
|
||||
fixer && runningFixers.has(fixer) ? (
|
||||
<RotatingLoopIcon />
|
||||
) : (
|
||||
match(data?.type)
|
||||
.with('error', () => <Error color="error" />)
|
||||
.with('warning', () => <Warning color="warning" />)
|
||||
.with('healthy', () => <CheckCircle color="success" />)
|
||||
.with('info', () => <Info color="info" />)
|
||||
.otherwise(() =>
|
||||
systemHealthQuery.isLoading ? (
|
||||
<RotatingLoopIcon />
|
||||
) : (
|
||||
<QuestionMark />
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow hover key={check}>
|
||||
<TableCell width="1em">{icon}</TableCell>
|
||||
<TableCell sx={{ minWidth: '10em' }}>
|
||||
<Typography>{prettyName}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{data?.type !== 'healthy' && (
|
||||
<Typography>{data?.context}</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{fixer && data && data.type !== 'healthy' && (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={runningFixers.has(fixer)}
|
||||
onClick={() => {
|
||||
// Have to make a new set of react won't re-render
|
||||
setRunningFixers((prev) => new Set([...prev, fixer]));
|
||||
runSystemFixer.mutate({ fixerId: fixer });
|
||||
}}
|
||||
>
|
||||
Attempt Auto-Fix
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>{checkRows}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const actualBackendUri = isEmpty(backendUri)
|
||||
? window.location.origin
|
||||
: backendUri;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap={2} useFlexGap>
|
||||
<PaddedPaper>
|
||||
<Typography sx={{ mb: 1 }} variant="h5">
|
||||
System Health
|
||||
</Typography>
|
||||
{systemHealthQuery.isLoading && <LinearProgress />}
|
||||
{renderHealthCheckResults()}
|
||||
</PaddedPaper>
|
||||
<PaddedPaper>
|
||||
<Typography sx={{ mb: 1 }} variant="h5">
|
||||
System Info
|
||||
</Typography>
|
||||
{systemSettings.isLoading && <LinearProgress />}
|
||||
{systemSettings.data && (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<strong>Tunarr Backend URL:</strong>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<Link href={actualBackendUri} target="_blank">
|
||||
{actualBackendUri}
|
||||
</Link>
|
||||
<CopyToClipboardButton content={actualBackendUri} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<strong>XMLTV Link:</strong>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<Link href={xmlTvLink} target="_blank">
|
||||
{xmlTvLink}
|
||||
</Link>
|
||||
<CopyToClipboardButton content={xmlTvLink} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<strong>Channels M3U Link:</strong>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<Link href={m3uLink} target="_blank">
|
||||
{m3uLink}
|
||||
</Link>
|
||||
<CopyToClipboardButton content={m3uLink} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<strong>Logs Directory:</strong>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span>{systemSettings.data.logging.logsDirectory}</span>
|
||||
<CopyToClipboardButton
|
||||
content={systemSettings.data.logging.logsDirectory}
|
||||
/>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<strong>Backups:</strong>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
{isEmpty(systemSettings.data.backup.configurations)
|
||||
? 'Disabled'
|
||||
: 'Enabled'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</PaddedPaper>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,29 @@
|
||||
import GuidePage from '@/pages/guide/GuidePage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { StatusPage } from '@/pages/StatusPage';
|
||||
import { setShowWelcome } from '@/store/themeEditor/actions';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: () => <GuidePage channelId="all" />,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData({
|
||||
queryFn() {
|
||||
return context.tunarrApiClientProvider().getSystemSettings();
|
||||
},
|
||||
queryKey: ['system', 'settings'],
|
||||
});
|
||||
|
||||
const systemState = await context.queryClient.ensureQueryData({
|
||||
queryFn() {
|
||||
return context.tunarrApiClientProvider().getSystemState();
|
||||
},
|
||||
queryKey: ['system', 'state'],
|
||||
});
|
||||
|
||||
setShowWelcome(systemState.isFreshSettings);
|
||||
if (systemState.isFreshSettings) {
|
||||
throw redirect({
|
||||
to: '/welcome',
|
||||
});
|
||||
}
|
||||
},
|
||||
component: StatusPage,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { isEmpty, trimEnd } from 'lodash-es';
|
||||
import useStore from '..';
|
||||
|
||||
export const useSettings = () => {
|
||||
return useStore(({ settings }) => settings);
|
||||
};
|
||||
|
||||
export const useBackendUrl = () => {
|
||||
const { backendUri } = useSettings();
|
||||
return trimEnd(
|
||||
(isEmpty(backendUri) ? window.location.origin : backendUri).trim(),
|
||||
'/',
|
||||
);
|
||||
};
|
||||
|
||||
export const useChannelTableVisibilityModel = () =>
|
||||
useStore(({ settings }) => settings.ui.channelTableColumnModel);
|
||||
|
||||
@@ -20,6 +20,11 @@ export const updateShowWelcomeState = () => {
|
||||
});
|
||||
};
|
||||
|
||||
export const setShowWelcome = (show: boolean) =>
|
||||
useStore.setState((state) => {
|
||||
state.theme.showWelcome = show;
|
||||
});
|
||||
|
||||
export const resetShowWelcomeState = () => {
|
||||
useStore.setState((state) => {
|
||||
state.theme.showWelcome = true;
|
||||
|
||||
Reference in New Issue
Block a user