feat: introduce health checks and system status page (#885)

This commit is contained in:
Christian Benincasa
2024-10-17 17:22:09 -04:00
committed by GitHub
parent 663a849be8
commit 03f57e0ed5
27 changed files with 1177 additions and 102 deletions

View File

@@ -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) {

View File

@@ -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',
{

View File

@@ -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),

View File

@@ -44,6 +44,7 @@ export const MinimalProgramGroupingFields: ProgramGroupingFields = [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.year',
// 'programGrouping.index',
];
type FillerShowFields = readonly `fillerShow.${keyof RawFillerShow}`[];

View File

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

View File

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

View File

@@ -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() {

View 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,
};
}
}

View File

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

View 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;
}
}

View File

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

View 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>;
}

View File

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

View File

@@ -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`,
};
}
}

View File

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

View File

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

View File

@@ -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,

View File

@@ -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"

View File

@@ -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;

View File

@@ -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

View File

@@ -0,0 +1,6 @@
import { useBackendUrl } from '@/store/settings/selectors';
export const useM3ULink = () => {
const backendUrl = useBackendUrl();
return `${backendUrl}/api/channels.m3u`;
};

View File

@@ -0,0 +1,8 @@
import { useApiQuery } from './useApiQuery';
export const useSystemHealthChecks = () => {
return useApiQuery({
queryKey: ['system', 'health'],
queryFn: (api) => api.getSystemHealth(),
});
};

View 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`;
};

View 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>
);
};

View File

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

View File

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

View File

@@ -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;