chore: add Scalar generated API docs (#856)

This commit is contained in:
Christian Benincasa
2025-01-24 11:21:04 -05:00
committed by GitHub
parent 67881e21a8
commit 8b703d50de
37 changed files with 672 additions and 249 deletions

17
docs/api-docs.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<title>Scalar API Reference</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1" />
</head>
<body>
<!-- Need a Custom Header? Check out this example https://codepen.io/scalarorg/pen/VwOXqam -->
<script
id="api-reference"
data-url="/generated/tunarr-latest-openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>

0
docs/generated/.gitkeep Normal file
View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -37,6 +37,7 @@ theme:
nav:
- Home: index.md
- API Docs: api-docs.html
- Getting Started:
- Install: getting-started/installation.md
- Run: getting-started/run.md

82
pnpm-lock.yaml generated
View File

@@ -100,8 +100,8 @@ importers:
specifier: ^1.0.1
version: 1.0.1
'@scalar/fastify-api-reference':
specifier: ^1.25.33
version: 1.25.33
specifier: ^1.25.106
version: 1.25.106
'@tunarr/playlist':
specifier: ^1.1.0
version: 1.1.0
@@ -2116,16 +2116,16 @@ packages:
'@rushstack/ts-command-line@4.19.1':
resolution: {integrity: sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==}
'@scalar/fastify-api-reference@1.25.33':
resolution: {integrity: sha512-icj/WJg5sSfBplP5OL5+4hqQBXHUgmBNCpPW+4ZsJ/ZKPMKvnQ539okrBCplfFYtQHVjH2s0PoCUCXL7YnQcwA==}
'@scalar/fastify-api-reference@1.25.106':
resolution: {integrity: sha512-6BbqE4eHJLe8f/m3AePT/p0MulD/aipSAuhPxQl8SQmaiUlePcukruo72KhoyqVPO0WEoE1haE8SvRLJpAgzww==}
engines: {node: '>=18'}
'@scalar/openapi-types@0.1.2':
resolution: {integrity: sha512-TxiAbs9Rw5qnMs/vvg+4Zx1Xf/5RhJXf8w6JOYSgvp4d2IKkOKc9eSOhid8ySvz7bWCjF2yWd8eHNc/BFs8cXg==}
'@scalar/openapi-types@0.1.6':
resolution: {integrity: sha512-V+KnESyVJqorJzEN0QFlu3tAImCHjnvPov6QcQvjfY7s0+CjrI3rRO3oVIRlXURTQrQGrnhxvK0SkXGAZ+dxvw==}
engines: {node: '>=18'}
'@scalar/types@0.0.14':
resolution: {integrity: sha512-4yzW5d9nWtRE3eVNfLnuVUScSMf325PYJ9qCJ8CpaVP7hnWrTv9xGw/2n7csEKzu3QJkdff0myibHfxXJ6ICig==}
'@scalar/types@0.0.27':
resolution: {integrity: sha512-5f293PX78gwE3NwcBNXf7+qc9OIB6lYsRpzGZts7eaN3aN9i23ysWewF76DOs74oARRP5LgNBMC7JCo9dIxM1Q==}
engines: {node: '>=18'}
'@sec-ant/readable-stream@0.4.1':
@@ -2620,8 +2620,8 @@ packages:
react: '>=18.0.0'
react-dom: '>=18.0.0'
'@unhead/schema@1.11.7':
resolution: {integrity: sha512-j9uN7T63aUXrZ6yx2CfjVT7xZHjn0PZO7TPMaWqMFjneIH/NONKvDVCMEqDlXeqdSIERIYtk/xTHgCUMer5eyw==}
'@unhead/schema@1.11.18':
resolution: {integrity: sha512-a3TA/OJCRdfbFhcA3Hq24k1ZU1o9szicESrw8DZcGyQFacHnh84mVgnyqSkMnwgCmfN4kvjSiTBlLEHS6+wATw==}
'@vitejs/plugin-react-swc@3.7.1':
resolution: {integrity: sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==}
@@ -7791,7 +7791,7 @@ snapshots:
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
semver: 7.5.4
semver: 7.6.2
'@changesets/assemble-release-plan@6.0.3':
dependencies:
@@ -7801,7 +7801,7 @@ snapshots:
'@changesets/should-skip-package': 0.1.0
'@changesets/types': 6.0.0
'@manypkg/get-packages': 1.1.3
semver: 7.5.4
semver: 7.6.2
'@changesets/changelog-git@0.2.0':
dependencies:
@@ -7862,7 +7862,7 @@ snapshots:
'@manypkg/get-packages': 1.1.3
chalk: 2.4.2
fs-extra: 7.0.1
semver: 7.5.4
semver: 7.6.2
'@changesets/get-release-plan@4.0.3':
dependencies:
@@ -8619,7 +8619,7 @@ snapshots:
'@rushstack/ts-command-line': 4.19.1(@types/node@22.10.7)
lodash: 4.17.21
minimatch: 3.0.8
resolve: 1.22.8
resolve: 1.22.10
semver: 7.5.4
source-map: 0.6.1
typescript: 5.4.2
@@ -8897,7 +8897,7 @@ snapshots:
fs-extra: 7.0.1
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.8
resolve: 1.22.10
semver: 7.5.4
z-schema: 5.0.5
optionalDependencies:
@@ -8905,7 +8905,7 @@ snapshots:
'@rushstack/rig-package@0.5.2':
dependencies:
resolve: 1.22.8
resolve: 1.22.10
strip-json-comments: 3.1.1
'@rushstack/terminal@0.10.0(@types/node@22.10.7)':
@@ -8924,17 +8924,17 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@scalar/fastify-api-reference@1.25.33':
'@scalar/fastify-api-reference@1.25.106':
dependencies:
'@scalar/types': 0.0.14
'@scalar/types': 0.0.27
fastify-plugin: 4.5.1
'@scalar/openapi-types@0.1.2': {}
'@scalar/openapi-types@0.1.6': {}
'@scalar/types@0.0.14':
'@scalar/types@0.0.27':
dependencies:
'@scalar/openapi-types': 0.1.2
'@unhead/schema': 1.11.7
'@scalar/openapi-types': 0.1.6
'@unhead/schema': 1.11.18
'@sec-ant/readable-stream@0.4.1': {}
@@ -9460,7 +9460,7 @@ snapshots:
debug: 4.3.7
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.2
ts-api-utils: 1.0.3(typescript@5.4.3)
optionalDependencies:
typescript: 5.4.3
@@ -9505,7 +9505,7 @@ snapshots:
'@typescript-eslint/typescript-estree': 6.0.0(typescript@5.4.3)
eslint: 9.17.0(jiti@2.4.1)
eslint-scope: 5.1.1
semver: 7.5.4
semver: 7.6.2
transitivePeerDependencies:
- supports-color
- typescript
@@ -9547,7 +9547,7 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
'@unhead/schema@1.11.7':
'@unhead/schema@1.11.18':
dependencies:
hookable: 5.5.3
zhead: 2.2.4
@@ -10248,8 +10248,8 @@ snapshots:
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.1
es-define-property: 1.0.0
get-intrinsic: 1.2.4
es-define-property: 1.0.1
get-intrinsic: 1.2.6
set-function-length: 1.2.2
call-bound@1.0.3:
@@ -10660,6 +10660,10 @@ snapshots:
dependencies:
ms: 2.1.2
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.3.4(supports-color@5.5.0):
dependencies:
ms: 2.1.2
@@ -10703,9 +10707,9 @@ snapshots:
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.0
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.0.1
gopd: 1.2.0
define-properties@1.2.1:
dependencies:
@@ -11560,7 +11564,7 @@ snapshots:
dunder-proto: 1.0.1
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
gopd: 1.2.0
has-symbols: 1.1.0
@@ -11743,7 +11747,7 @@ snapshots:
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.0
es-define-property: 1.0.1
has-proto@1.0.3: {}
@@ -11757,7 +11761,7 @@ snapshots:
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.0.3
has-symbols: 1.1.0
has@1.0.4: {}
@@ -12703,7 +12707,7 @@ snapshots:
node-abi@3.51.0:
dependencies:
semver: 7.5.4
semver: 7.6.2
node-cache@5.1.2:
dependencies:
@@ -12806,7 +12810,7 @@ snapshots:
call-bind: 1.0.8
call-bound: 1.0.3
define-properties: 1.2.1
es-object-atoms: 1.0.0
es-object-atoms: 1.1.1
has-symbols: 1.1.0
object-keys: 1.1.1
@@ -13493,7 +13497,7 @@ snapshots:
resolve@1.19.0:
dependencies:
is-core-module: 2.13.1
is-core-module: 2.16.1
path-parse: 1.0.7
resolve@1.22.10:
@@ -13663,8 +13667,8 @@ snapshots:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.2.4
gopd: 1.0.1
get-intrinsic: 1.2.6
gopd: 1.2.0
has-property-descriptors: 1.0.2
set-function-name@2.0.2:
@@ -13758,7 +13762,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.5.4
semver: 7.6.2
slash@3.0.0: {}
@@ -14329,7 +14333,7 @@ snapshots:
bundle-require: 4.0.2(esbuild@0.19.12)
cac: 6.7.14
chokidar: 3.5.3
debug: 4.3.4(supports-color@5.5.0)
debug: 4.3.4
esbuild: 0.19.12
execa: 5.1.1
globby: 11.1.0

View File

@@ -35,7 +35,7 @@
"@fastify/static": "^8.0.1",
"@fastify/swagger": "^9.0.1",
"@iptv/xmltv": "^1.0.1",
"@scalar/fastify-api-reference": "^1.25.33",
"@scalar/fastify-api-reference": "^1.25.106",
"@tunarr/playlist": "^1.1.0",
"@tunarr/shared": "workspace:*",
"@tunarr/types": "workspace:*",

View File

@@ -30,6 +30,7 @@ import {
import schedule from 'node-schedule';
import path, { dirname } from 'path';
import 'reflect-metadata';
import { z } from 'zod';
import { HdhrApiRouter } from './api/hdhrApi.js';
import { apiRouter } from './api/index.js';
import { streamApi } from './api/streamApi.js';
@@ -76,7 +77,6 @@ export class Server {
) {}
async init() {
const start = performance.now();
this.logger.info(
'Using Tunarr database directory: %s',
this.serverOptions.databaseDirectory,
@@ -178,6 +178,42 @@ export class Server {
{
name: 'Channels',
},
{
name: 'Custom Shows',
},
{
name: 'Filler Lists',
},
{
name: 'Guide',
},
{
name: 'Media Source',
},
{
name: 'Programs',
},
{
name: 'Sessions',
},
{
name: 'Streaming',
},
{
name: 'HDHR',
},
{
name: 'Settings',
},
{
name: 'System',
},
{
name: 'Tasks',
},
{
name: 'Debug',
},
],
},
transform: jsonSchemaTransform,
@@ -189,6 +225,16 @@ export class Server {
// ? join(dirname(process.argv[1]), 'static')
// : undefined,
// })
// Hitting api docs on local instances of Tunarr is blocked on
// https://github.com/scalar/scalar/pull/4528
// .register(fastifyApiReference, {
// routePrefix: '/docs',
// configuration: {
// spec: {
// content: () => this.app.swagger(),
// },
// },
// })
.register(cors, {
origin: '*', // Testing
})
@@ -285,7 +331,7 @@ export class Server {
done();
})
.register(async (f) => {
.register(async (f: ServerType) => {
await f.register(fpStatic, {
root: path.join(
this.serverOptions.databaseDirectory,
@@ -295,14 +341,18 @@ export class Server {
decorateReply: false,
serve: false, // Use the interceptor
});
f.get<{ Params: { hash: string } }>(
f.get(
'/cache/images/:hash',
{
schema: {
hide: true,
params: z.object({ hash: z.string() }),
},
// Workaround for https://github.com/fastify/fastify/issues/4859
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onRequest: (req, res) => {
return req.serverCtx.cacheImageService.routerInterceptor(
req,
req.params.hash,
res,
);
},
@@ -312,15 +362,24 @@ export class Server {
},
);
f.delete('/api/cache/images', async (req, res) => {
try {
await req.serverCtx.cacheImageService.clearCache();
return res.status(200).send({ msg: 'Cache Image are Cleared' });
} catch (error) {
this.logger.error('Error deleting cached images', error);
return res.status(500).send('error');
}
});
f.delete(
'/api/cache/images',
{
schema: {
// TODO: Expose and add button to UI
hide: true,
},
},
async (req, res) => {
try {
await req.serverCtx.cacheImageService.clearCache();
return res.status(200).send({ msg: 'Cache Image are Cleared' });
} catch (error) {
this.logger.error('Error deleting cached images', error);
return res.status(500).send('error');
}
},
);
})
.register(async (f) => {
f.addHook('onError', (req, _, error, done) => {
@@ -328,7 +387,9 @@ export class Server {
done();
});
await f
.get('/', async (_, res) => res.redirect('/web', 302))
.get('/', { schema: { hide: true } }, async (_, res) =>
res.redirect('/web', 302),
)
.register(new HdhrApiRouter().router)
.register(apiRouter, { prefix: '/api' });
})
@@ -368,8 +429,6 @@ export class Server {
await updateXMLPromise;
const host = process.env['TUNARR_BIND_ADDR'] ?? '0.0.0.0';
this.app.after(() => {
this.app.gracefulShutdown(async (signal) => {
this.logger.info(
@@ -439,8 +498,15 @@ export class Server {
this.logger.debug('All done, shutting down!');
});
});
}
const url = await this.app.listen({
async initAndRun() {
const start = performance.now();
await this.init();
const host = process.env['TUNARR_BIND_ADDR'] ?? '0.0.0.0';
await this.app.listen({
host,
port: this.serverOptions.port,
});
@@ -466,7 +532,16 @@ export class Server {
},
level: 'success',
});
}
return { app: this.app, url };
getOpenApiDocument() {
return this.app.swagger();
}
close() {
if (this.app) {
return this.app.close();
}
return Promise.resolve();
}
}

View File

@@ -10,6 +10,7 @@ import { scheduleTimeSlots } from '@tunarr/shared';
import {
BasicIdParamSchema,
BasicPagingSchema,
GetChannelProgrammingResponseSchema,
TimeSlotScheduleSchema,
UpdateChannelProgrammingRequestSchema,
} from '@tunarr/types/api';
@@ -59,7 +60,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
'/channels',
{
schema: {
operationId: 'getChannelsV2',
operationId: 'getChannels',
tags: ['Channels'],
response: {
200: z.array(ChannelSchema),
@@ -292,7 +293,7 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
querystring: BasicPagingSchema,
tags: ['Channels'],
response: {
200: CondensedChannelProgrammingSchema,
200: GetChannelProgrammingResponseSchema,
404: z.object({ error: z.string() }),
},
},
@@ -389,6 +390,8 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
{
schema: {
params: BasicIdParamSchema,
operationId: 'GetChannelFallbacks',
description: "Returns a channel's fallback programs.",
tags: ['Channels'],
querystring: ChannelLineupQuery,
response: {

View File

@@ -25,6 +25,7 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows',
{
schema: {
tags: ['Custom Shows'],
response: {
200: z.array(CustomShowSchema),
},
@@ -48,6 +49,7 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows/:id',
{
schema: {
tags: ['Custom Shows'],
params: IdPathParamSchema,
response: {
200: CustomShowSchema,
@@ -77,6 +79,7 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows/:id',
{
schema: {
tags: ['Custom Shows'],
params: IdPathParamSchema,
body: UpdateCustomShowRequestSchema,
response: {
@@ -108,6 +111,7 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows/:id/programs',
{
schema: {
tags: ['Custom Shows'],
params: IdPathParamSchema,
response: {
200: z.array(CustomProgramSchema),
@@ -126,6 +130,9 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows',
{
schema: {
tags: ['Custom Shows'],
operationId: 'createCustomShow',
description: 'Creates a new Custom Show',
body: CreateCustomShowRequestSchema,
response: {
201: z.object({ id: z.string() }),
@@ -143,6 +150,9 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
'/custom-shows/:id',
{
schema: {
tags: ['Custom Shows'],
operationId: 'deleteCustomShow',
description: 'Delets a custom show with the given ID',
params: IdPathParamSchema,
response: {
200: z.object({ id: z.string() }),

View File

@@ -16,6 +16,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
'/ffmpeg/probe',
{
schema: {
tags: ['Debug'],
querystring: z.object({
path: z.string(),
}),
@@ -34,6 +35,7 @@ export const debugFfmpegApiRouter: RouterPluginAsyncCallback = async (
'/ffmpeg/pipeline',
{
schema: {
tags: ['Debug'],
querystring: z.object({
channel: z.coerce.number().or(z.string()),
path: z.string(),

View File

@@ -14,6 +14,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
'/jellyfin/libraries',
{
schema: {
tags: ['Debug'],
querystring: z.object({
userId: z.string(),
uri: z.string().url(),
@@ -35,6 +36,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
'/jellyfin/library/items',
{
schema: {
tags: ['Debug'],
querystring: z
.object({
uri: z.string().url(),
@@ -69,6 +71,7 @@ export const DebugJellyfinApiRouter: RouterPluginAsyncCallback = async (
'/jellyfin/match_program/:id',
{
schema: {
tags: ['Debug'],
params: z.object({
id: z.string(),
}),

View File

@@ -12,6 +12,7 @@ export const DebugPlexApiRouter: RouterPluginAsyncCallback = async (
'/plex/stream_details',
{
schema: {
tags: ['Debug'],
querystring: z.object({
key: z.string(),
mediaSource: z.string(),

View File

@@ -34,6 +34,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
'/streams/offline',
{
schema: {
tags: ['Debug'],
querystring: z.object({
duration: z.coerce.number().default(30_000),
useNewPipeline: TruthyQueryParam.optional(),
@@ -91,6 +92,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
'/streams/error',
{
schema: {
tags: ['Debug'],
querystring: z.object({
useNewPipeline: TruthyQueryParam.optional(),
}),
@@ -191,6 +193,7 @@ export const debugStreamApiRouter: RouterPluginAsyncCallback = async (
'/streams/programs/:id',
{
schema: {
tags: ['Debug'],
params: z.object({
id: z.string(),
}),

View File

@@ -26,11 +26,9 @@ import { debugFfmpegApiRouter } from './debug/debugFfmpegApi.ts';
import { DebugJellyfinApiRouter } from './debug/debugJellyfinApi.js';
import { debugStreamApiRouter } from './debug/debugStreamApi.js';
const ChannelQuerySchema = {
querystring: z.object({
channelId: z.string(),
}),
};
const ChannelQuerySchema = z.object({
channelId: z.string(),
});
export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
await fastify
@@ -50,7 +48,10 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
fastify.get(
'/debug/helpers/current_program',
{
schema: ChannelQuerySchema,
schema: {
tags: ['Debug'],
querystring: ChannelQuerySchema,
},
},
async (req, res) => {
const channel = await req.serverCtx.channelDB.getChannelAndPrograms(
@@ -78,17 +79,20 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
const CreateLineupSchema = {
querystring: ChannelQuerySchema.querystring.extend({
live: z.coerce.boolean(),
startTime: z.coerce.number().optional(),
endTime: z.coerce.number().optional(),
}),
};
const CreateLineupSchema = ChannelQuerySchema.extend({
live: z.coerce.boolean(),
startTime: z.coerce.number().optional(),
endTime: z.coerce.number().optional(),
});
fastify.get(
'/debug/helpers/create_guide',
{ schema: CreateLineupSchema },
{
schema: {
tags: ['Debug'],
querystring: CreateLineupSchema,
},
},
async (req, res) => {
const channel = await req.serverCtx.channelDB.getChannelAndPrograms(
req.query.channelId,
@@ -123,6 +127,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
'/debug/helpers/channels/:id/build_guide',
{
schema: {
tags: ['Debug'],
params: z.object({
id: z.string(),
}),
@@ -197,16 +202,17 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
const RandomFillerSchema = {
querystring: CreateLineupSchema.querystring.extend({
maxDuration: z.coerce.number(),
}),
};
const RandomFillerSchema = CreateLineupSchema.extend({
maxDuration: z.coerce.number(),
});
fastify.get(
'/debug/helpers/random_filler',
{
schema: RandomFillerSchema,
schema: {
tags: ['Debug'],
querystring: RandomFillerSchema,
},
},
async (req, res) => {
const channel = await req.serverCtx.channelDB.getChannel(
@@ -227,24 +233,33 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get('/debug/db/backup', async (_, res) => {
await container
.get<ArchiveDatabaseBackupFactory>(ArchiveDatabaseBackupKey)({
type: 'file',
outputPath: os.tmpdir(),
archiveFormat: 'tar',
gzip: true,
maxBackups: 3,
})
.backup();
fastify.get(
'/debug/db/backup',
{
schema: {
tags: ['Debug'],
},
},
async (_, res) => {
await container
.get<ArchiveDatabaseBackupFactory>(ArchiveDatabaseBackupKey)({
type: 'file',
outputPath: os.tmpdir(),
archiveFormat: 'tar',
gzip: true,
maxBackups: 3,
})
.backup();
return res.send();
});
return res.send();
},
);
fastify.post(
'/debug/plex/:programId/update_external_ids',
{
schema: {
tags: ['Debug'],
params: z.object({
programId: z.string(),
}),
@@ -268,6 +283,7 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
'/debug/helpers/promote_lineup',
{
schema: {
tags: ['Debug'],
querystring: z.object({
channelId: z.string().uuid(),
}),
@@ -284,15 +300,24 @@ export const debugApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get('/debug/channels/reload_all_lineups', async (req, res) => {
await req.serverCtx.channelDB.loadAllLineupConfigs(true);
return res.send();
});
fastify.get(
'/debug/channels/reload_all_lineups',
{
schema: {
tags: ['Debug'],
},
},
async (req, res) => {
await req.serverCtx.channelDB.loadAllLineupConfigs(true);
return res.send();
},
);
fastify.get(
'/debug/db/test_direct_access',
{
schema: {
tags: ['Debug'],
querystring: z.object({
id: z.string(),
}),

View File

@@ -24,20 +24,33 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
className: 'FfmpegSettingsApi',
});
fastify.get('/ffmpeg-settings', (req, res) => {
try {
const ffmpeg = req.serverCtx.settings.ffmpegSettings();
return res.send(ffmpeg);
} catch (err) {
logger.error(err);
return res.status(500).send('error');
}
});
fastify.get(
'/ffmpeg-settings',
{
schema: {
tags: ['Settings'],
response: {
200: FfmpegSettingsSchema,
500: z.literal('error'),
},
},
},
async (req, res) => {
try {
const ffmpeg = req.serverCtx.settings.ffmpegSettings();
return res.send(makeWritable(ffmpeg));
} catch (err) {
logger.error(err);
return res.status(500).send('error');
}
},
);
fastify.put(
'/ffmpeg-settings',
{
schema: {
tags: ['Settings'],
body: FfmpegSettingsSchema,
response: {
200: FfmpegSettingsSchema,
@@ -98,8 +111,20 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
},
);
fastify.post<{ Body: { ffmpegPath: string } }>(
fastify.post(
'/ffmpeg-settings',
{
schema: {
tags: ['Settings'],
body: z.object({
ffmpegPath: z.string(),
}),
repsonse: {
200: FfmpegSettingsSchema,
500: z.literal('error'),
},
},
},
async (req, res) => {
// RESET
try {
@@ -137,6 +162,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
'/transcode_configs',
{
schema: {
tags: ['Settings'],
response: {
200: z.array(TranscodeConfigSchema),
},
@@ -153,6 +179,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
'/transcode_configs/:id',
{
schema: {
tags: ['Settings'],
params: z.object({
id: z.string().uuid(),
}),
@@ -178,6 +205,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
'/transcode_configs',
{
schema: {
tags: ['Settings'],
body: TranscodeConfigSchema.omit({
id: true,
}),
@@ -198,6 +226,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
'/transcode_configs/:id',
{
schema: {
tags: ['Settings'],
body: TranscodeConfigSchema,
params: IdPathParamSchema,
response: {
@@ -218,6 +247,7 @@ export const ffmpegSettingsRouter: RouterPluginCallback = (
'/transcode_configs/:id',
{
schema: {
tags: ['Settings'],
params: IdPathParamSchema,
response: {
200: z.void(),

View File

@@ -21,6 +21,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists',
{
schema: {
tags: ['Filler Lists'],
response: {
200: z.array(FillerListSchema),
},
@@ -43,6 +44,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists/:id',
{
schema: {
tags: ['Filler Lists'],
params: z.object({ id: fillerShowIdSchema }),
response: {
200: FillerListSchema,
@@ -68,6 +70,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists/:id',
{
schema: {
tags: ['Filler Lists'],
params: z.object({ id: fillerShowIdSchema }),
response: {
200: z.void(),
@@ -89,6 +92,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists',
{
schema: {
tags: ['Filler Lists'],
body: CreateFillerListRequestSchema,
response: {
201: z.object({ id: z.string() }),
@@ -105,6 +109,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists/:id',
{
schema: {
tags: ['Filler Lists'],
params: z.object({
id: fillerShowIdSchema,
}),
@@ -124,8 +129,6 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(404).send();
}
console.log('sending response');
return res.send({
id: result.uuid,
name: result.name,
@@ -138,6 +141,7 @@ export const fillerListsApi: RouterPluginAsyncCallback = async (fastify) => {
'/filler-lists/:id/programs',
{
schema: {
tags: ['Filler Lists'],
params: IdPathParamSchema.extend({ id: fillerShowIdSchema }),
response: {
200: FillerListProgrammingSchema,

View File

@@ -12,30 +12,47 @@ export const guideRouter: RouterPluginCallback = (fastify, _opts, done) => {
className: 'GuideApi',
});
fastify.get('/guide/status', async (req, res) => {
try {
const s = await req.serverCtx.guideService.getStatus();
return res.send(s);
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
});
fastify.get(
'/guide/status',
{
schema: {
tags: ['Guide'],
},
},
async (req, res) => {
try {
const s = await req.serverCtx.guideService.getStatus();
return res.send(s);
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
},
);
fastify.get('/guide/debug', async (req, res) => {
try {
const s = await req.serverCtx.guideService.get();
return res.send(s);
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
});
fastify.get(
'/guide/debug',
{
schema: {
tags: ['Debug'],
},
},
async (req, res) => {
try {
const s = await req.serverCtx.guideService.get();
return res.send(s);
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
},
);
fastify.get(
'/guide/channels',
{
schema: {
tags: ['Guide'],
querystring: z.object({
dateFrom: z.coerce.date(),
dateTo: z.coerce.date(),
@@ -61,29 +78,41 @@ export const guideRouter: RouterPluginCallback = (fastify, _opts, done) => {
},
);
fastify.get<{
Params: { id: string };
Querystring: { dateFrom: string; dateTo: string };
}>('/guide/channels/:number', async (req, res) => {
try {
// TODO determine if these params are numbers or strings
const dateFrom = new Date(req.query.dateFrom);
const dateTo = new Date(req.query.dateTo);
const lineup = await req.serverCtx.guideService.getChannelLineup(
req.params.id,
dateFrom,
dateTo,
);
if (lineup == null) {
return res.status(404).send('Channel not found in TV guide');
} else {
return res.send(lineup);
fastify.get(
'/guide/channels/:id',
{
schema: {
tags: ['Guide'],
params: z.object({
id: z.string(),
}),
querystring: z.object({
dateFrom: z.string().pipe(z.date()),
dateTo: z.string().pipe(z.date()),
}),
},
},
async (req, res) => {
try {
// TODO determine if these params are numbers or strings
const dateFrom = req.query.dateFrom;
const dateTo = req.query.dateTo;
const lineup = await req.serverCtx.guideService.getChannelLineup(
req.params.id,
dateFrom,
dateTo,
);
if (lineup == null) {
return res.status(404).send('Channel not found in TV guide');
} else {
return res.send(lineup);
}
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
} catch (err) {
logger.error('%s, %O', req.routeOptions.url, err);
return res.status(500).send('error');
}
});
},
);
done();
};

View File

@@ -28,6 +28,10 @@ export class HdhrApiRouter {
routeOpts.config = {};
}
routeOpts.config.disableRequestLogging = true;
if (!routeOpts.schema) {
routeOpts.schema = {};
}
routeOpts.schema.hide = true;
});
fastify.get('/device.xml', (req, res) => {
@@ -37,27 +41,44 @@ export class HdhrApiRouter {
.send(req.serverCtx.hdhrService.getHdhrDeviceXml(host));
});
fastify.get('/discover.json', (req, res) => {
return res.send(
req.serverCtx.hdhrService.getHdhrDevice(
req.protocol + '://' + req.host,
),
);
});
fastify.get(
'/discover.json',
{
schema: {
tags: ['HDHR'],
},
},
(req, res) => {
return res.send(
req.serverCtx.hdhrService.getHdhrDevice(
req.protocol + '://' + req.host,
),
);
},
);
fastify.get('/lineup_status.json', (_, res) => {
return res.send({
ScanInProgress: 0,
ScanPossible: 1,
Source: 'Cable',
SourceList: ['Cable'],
});
});
fastify.get(
'/lineup_status.json',
{
schema: {
tags: ['HDHR'],
},
},
(_, res) => {
return res.send({
ScanInProgress: 0,
ScanPossible: 1,
Source: 'Cable',
SourceList: ['Cable'],
});
},
);
fastify.get(
'/lineup.json',
{
schema: {
tags: ['HDHR'],
response: {
200: z.array(HdhrLineupSchema),
},

View File

@@ -20,6 +20,7 @@ export const hdhrSettingsRouter: RouterPluginCallback = (
'/hdhr-settings',
{
schema: {
tags: ['Settings'],
response: {
200: HdhrSettingsSchema,
500: BaseErrorSchema,
@@ -41,6 +42,7 @@ export const hdhrSettingsRouter: RouterPluginCallback = (
'/hdhr-settings',
{
schema: {
tags: ['Settings'],
body: HdhrSettingsSchema,
response: {
200: HdhrSettingsSchema,
@@ -83,6 +85,7 @@ export const hdhrSettingsRouter: RouterPluginCallback = (
'/hdhr-settings',
{
schema: {
tags: ['Settings'],
response: {
200: HdhrSettingsSchema,
500: BaseErrorSchema,

View File

@@ -72,6 +72,7 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/version',
{
schema: {
tags: ['System'],
response: {
200: VersionApiResponseSchema,
500: z.void(),
@@ -99,25 +100,33 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get('/ffmpeg-info', async (req, res) => {
const info = new FfmpegInfo(req.serverCtx.settings);
const [audioEncoders, videoEncoders] = await Promise.all([
run(async () => {
const res = await info.getAvailableAudioEncoders();
return isError(res) ? [] : res;
}),
run(async () => {
const res = await info.getAvailableVideoEncoders();
return isError(res) ? [] : res;
}),
]);
const hwAccels = await info.getHwAccels();
return res.send({
audioEncoders,
videoEncoders,
hardwareAccelerationTypes: hwAccels,
});
});
fastify.get(
'/ffmpeg-info',
{
schema: {
tags: ['System'],
},
},
async (req, res) => {
const info = new FfmpegInfo(req.serverCtx.settings);
const [audioEncoders, videoEncoders] = await Promise.all([
run(async () => {
const res = await info.getAvailableAudioEncoders();
return isError(res) ? [] : res;
}),
run(async () => {
const res = await info.getAvailableVideoEncoders();
return isError(res) ? [] : res;
}),
]);
const hwAccels = await info.getHwAccels();
return res.send({
audioEncoders,
videoEncoders,
hardwareAccelerationTypes: hwAccels,
});
},
);
fastify.post('/upload/image', async (req, res) => {
try {
@@ -189,6 +198,9 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
fastify.route({
url: '/xmltv.xml',
method: ['HEAD', 'GET'],
schema: {
tags: ['Streaming'],
},
handler: async (req, res) => {
try {
const host = `${req.protocol}://${req.host}`;
@@ -219,6 +231,9 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
// CHANNELS.M3U Download
fastify.route({
url: '/channels.m3u',
schema: {
tags: ['Streaming'],
},
method: ['HEAD', 'GET'],
handler: async (req, res) => {
try {
@@ -233,15 +248,28 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
},
});
fastify.delete('/channels.m3u', async (req, res) => {
await req.serverCtx.m3uService.regenerateCache();
return res.send(204);
});
fastify.delete(
'/channels.m3u',
{
schema: {
tags: ['Streaming'],
description: 'Clears the channels m3u cache',
response: {
204: z.void(),
},
},
},
async (req, res) => {
await req.serverCtx.m3uService.regenerateCache();
return res.status(204).send();
},
);
fastify.get(
'/plex',
{
schema: {
hide: true,
querystring: z.object({ id: z.string(), path: z.string() }),
},
},

View File

@@ -43,6 +43,14 @@ function isNonEmptyTyped<T>(f: T[]): f is [T, ...T[]] {
}
export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
fastify.addHook('onRoute', (routeOptions) => {
if (!routeOptions.schema) {
routeOptions.schema = {};
}
routeOptions.schema.hide = true;
});
fastify.post(
'/jellyfin/login',
{
@@ -80,7 +88,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
const response = await api.getUserViews();
if (isQueryError(response)) {
throw response;
throw new Error(response.message);
}
const sanitizedResponse: JellyfinLibraryItemsResponseTyp = {
@@ -180,7 +188,7 @@ export const jellyfinApiRouter: RouterPluginCallback = (fastify, _, done) => {
);
if (isQueryError(response)) {
throw response;
throw new Error(response.message);
}
return res.send(response.data);

View File

@@ -32,6 +32,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources',
{
schema: {
tags: ['Media Source'],
response: {
200: z.array(MediaSourceSettingsSchema),
500: z.string(),
@@ -70,6 +71,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources/:id/status',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema,
response: {
200: z.object({
@@ -129,6 +131,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources/foreignstatus',
{
schema: {
tags: ['Media Source'],
body: z.object({
name: z.string().optional(),
accessToken: z.string(),
@@ -189,6 +192,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources/:id',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema,
response: {
200: z.void(),
@@ -247,6 +251,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources/:id',
{
schema: {
tags: ['Media Source'],
params: BasicIdParamSchema,
body: UpdateMediaSourceRequestSchema,
response: {
@@ -300,6 +305,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/media-sources',
{
schema: {
tags: ['Media Source'],
body: InsertMediaSourceRequestSchema,
response: {
201: z.object({
@@ -349,6 +355,7 @@ export const mediaSourceRouter: RouterPluginAsyncCallback = async (
'/plex/status',
{
schema: {
tags: ['Media Source'],
querystring: z.object({
serverName: z.string(),
}),

View File

@@ -80,6 +80,7 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/metadata/external',
{
schema: {
hide: true,
querystring: ExternalMetadataQuerySchema,
},
config: {

View File

@@ -20,6 +20,7 @@ export const plexSettingsRouter: RouterPluginCallback = (
'/plex-settings',
{
schema: {
tags: ['Settings'],
response: {
200: PlexStreamSettingsSchema,
500: z.string(),
@@ -43,6 +44,7 @@ export const plexSettingsRouter: RouterPluginCallback = (
'/plex-settings',
{
schema: {
tags: ['Settings'],
body: PlexStreamSettingsSchema,
response: {
200: PlexStreamSettingsSchema,
@@ -85,6 +87,7 @@ export const plexSettingsRouter: RouterPluginCallback = (
'/plex-settings',
{
schema: {
tags: ['Settings'],
response: {
200: PlexStreamSettingsSchema,
500: z.string(),

View File

@@ -71,6 +71,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programs/:id',
{
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
},
},
@@ -98,6 +99,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programs/:id/thumb',
{
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
querystring: z.object({
width: z.number().optional(),
@@ -316,6 +318,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programs/:id/external-link',
{
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
querystring: z.object({
forward: z.coerce.boolean().default(true),
@@ -392,6 +395,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programming/:externalId',
{
schema: {
tags: ['Programs'],
operationId: 'getProgramByExternalId',
params: LookupExternalProgrammingSchema,
response: {
@@ -427,6 +431,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programming/batch/lookup',
{
schema: {
tags: ['Programs'],
operationId: 'batchGetProgramsByExternalIds',
body: BatchLookupExternalProgrammingSchema,
response: {
@@ -445,6 +450,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programming/shows/:id',
{
schema: {
tags: ['Programs'],
params: z.object({
id: z.string().uuid(),
}),
@@ -504,6 +510,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programming/seasons/:id',
{
schema: {
tags: ['Programs'],
params: z.object({
id: z.string().uuid(),
}),
@@ -536,6 +543,7 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
'/programming/shows/:id/seasons',
{
schema: {
tags: ['Programs'],
params: z.object({
id: z.string().uuid(),
}),

View File

@@ -15,6 +15,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/sessions',
{
schema: {
tags: ['Sessions'],
response: {
200: z.record(z.array(ChannelSessionsResponseSchema)),
},
@@ -52,6 +53,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/channels/:id/sessions',
{
schema: {
tags: ['Sessions'],
params: z.object({
id: z.coerce.number().or(z.string().uuid()),
}),
@@ -103,6 +105,7 @@ export const sessionApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/channels/:id/sessions',
{
schema: {
tags: ['Sessions'],
params: z.object({
id: z.coerce.number().or(z.string().uuid()),
}),

View File

@@ -41,6 +41,7 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
'/stream/channels/:id',
{
schema: {
tags: ['Streaming'],
params: z.object({
id: z.coerce.number().or(z.string()),
}),
@@ -88,6 +89,9 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
'/stream/channels/:id.ts',
{
schema: {
tags: ['Streaming'],
description:
'Returns a continuous, direct MPEGTS video stream for the given channel',
params: z.object({
id: z.coerce.number().or(z.string()),
}),
@@ -198,6 +202,7 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
'/stream/channels/:id/radio.ts',
{
schema: {
hide: true,
params: z.object({
id: z.coerce.number().or(z.string().uuid()),
}),
@@ -267,6 +272,7 @@ export const streamApi: RouterPluginAsyncCallback = async (fastify) => {
'/stream/channels/:id.m3u8',
{
schema: {
tags: ['Streaming'],
params: z.object({
id: z.string().uuid().or(z.coerce.number()),
}),

View File

@@ -20,15 +20,24 @@ 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/health',
{
schema: {
tags: ['System'],
},
},
async (req, res) => {
const results = await req.serverCtx.healthCheckService.runAll();
return res.send(results);
},
);
fastify.get(
'/system/settings',
{
schema: {
tags: ['System', 'Settings'],
response: {
200: SystemSettingsResponseSchema,
},
@@ -40,14 +49,23 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
},
);
fastify.get('/system/state', async (req, res) => {
return res.send(req.serverCtx.settings.migrationState);
});
fastify.get(
'/system/state',
{
schema: {
tags: ['System'],
},
},
async (req, res) => {
return res.send(req.serverCtx.settings.migrationState);
},
);
fastify.post(
'/system/fixers/:fixerId/run',
{
schema: {
tags: ['System'],
params: z.object({
fixerId: z.string(),
}),
@@ -78,6 +96,7 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
'/system/settings',
{
schema: {
tags: ['System', 'Settings'],
body: UpdateSystemSettingsRequestSchema,
response: {
200: SystemSettingsResponseSchema,
@@ -118,6 +137,7 @@ export const systemApiRouter: RouterPluginAsyncCallback = async (
'/system/settings/backup',
{
schema: {
tags: ['System', 'Settings'],
body: UpdateBackupSettingsRequestSchema,
response: {
200: BackupSettingsSchema,

View File

@@ -18,6 +18,7 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/jobs',
{
schema: {
tags: ['System', 'Tasks'],
response: {
200: z.array(TaskSchema),
},
@@ -61,6 +62,7 @@ export const tasksApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/jobs/:id/run',
{
schema: {
tags: ['System', 'Tasks'],
params: z.object({
id: z.string(),
background: z.boolean().default(true),

View File

@@ -3,7 +3,6 @@ import { FfmpegText } from '@/ffmpeg/ffmpegText.js';
import { VideoStream } from '@/stream/VideoStream.js';
import { TruthyQueryParam } from '@/types/schemas.js';
import { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { isProduction } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { makeLocalUrl } from '@/util/serverUtil.js';
import { ChannelStreamModeSchema } from '@tunarr/types/schemas';
@@ -29,55 +28,61 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
className: 'VideoApi',
});
fastify.get('/setup', async (req, res) => {
const ffmpegSettings = req.serverCtx.settings.ffmpegSettings();
// Check if ffmpeg path is valid
if (!fsSync.existsSync(ffmpegSettings.ffmpegExecutablePath)) {
logger.error(
`FFMPEG path (${ffmpegSettings.ffmpegExecutablePath}) is invalid. The file (executable) doesn't exist.`,
);
return res
.status(500)
.send(
fastify.get(
'/setup',
{
schema: { hide: true },
},
async (req, res) => {
const ffmpegSettings = req.serverCtx.settings.ffmpegSettings();
// Check if ffmpeg path is valid
if (!fsSync.existsSync(ffmpegSettings.ffmpegExecutablePath)) {
logger.error(
`FFMPEG path (${ffmpegSettings.ffmpegExecutablePath}) is invalid. The file (executable) doesn't exist.`,
);
}
logger.info(`\r\nStream starting. Channel: 1 (Tunarr)`);
return res
.status(500)
.send(
`FFMPEG path (${ffmpegSettings.ffmpegExecutablePath}) is invalid. The file (executable) doesn't exist.`,
);
}
const ffmpeg = new FfmpegText(
ffmpegSettings,
'Tunarr (No Channels Configured)',
'Configure your channels using the Tunarr Web UI',
);
logger.info(`\r\nStream starting. Channel: 1 (Tunarr)`);
const buffer = new Readable();
buffer._read = () => {};
const ffmpeg = new FfmpegText(
ffmpegSettings,
'Tunarr (No Channels Configured)',
'Configure your channels using the Tunarr Web UI',
);
ffmpeg.on('data', (data) => {
buffer.push(data);
});
const buffer = new Readable();
buffer._read = () => {};
ffmpeg.on('error', (err) => {
logger.error('FFMPEG ERROR', err);
buffer.push(null);
void res.status(500).send('FFMPEG ERROR');
return;
});
ffmpeg.on('data', (data) => {
buffer.push(data);
});
ffmpeg.on('close', () => {
buffer.push(null);
});
ffmpeg.on('error', (err) => {
logger.error('FFMPEG ERROR', err);
buffer.push(null);
void res.status(500).send('FFMPEG ERROR');
return;
});
res.raw.on('close', () => {
// on HTTP close, kill ffmpeg
ffmpeg.kill();
logger.info(`\r\nStream ended. Channel: 1 (Tunarr)`);
});
ffmpeg.on('close', () => {
buffer.push(null);
});
return res.send(buffer);
});
res.raw.on('close', () => {
// on HTTP close, kill ffmpeg
ffmpeg.kill();
logger.info(`\r\nStream ended. Channel: 1 (Tunarr)`);
});
return res.send(buffer);
},
);
/**
* Internal endpoint which returns the single, raw stream for a video
@@ -87,7 +92,7 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/stream',
{
schema: {
hide: isProduction,
hide: true,
querystring: z.object({
channel: z.coerce.number().or(z.string().uuid()),
audioOnly: TruthyQueryParam.catch(false),
@@ -182,6 +187,9 @@ export const videoApiRouter: RouterPluginAsyncCallback = async (fastify) => {
'/ffmpeg/playlist',
{
schema: {
tags: ['Streaming'],
description:
'Return a playlist in ffconcat file format for the given channel',
querystring: FfmpegPlaylistQuerySchema,
},
},

View File

@@ -24,6 +24,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
'/xmltv-settings',
{
schema: {
tags: ['Settings'],
response: {
200: XmlTvSettingsSchema,
500: z.string(),
@@ -34,6 +35,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
try {
return res.send(req.serverCtx.settings.xmlTvSettings());
} catch (err) {
logger.error(err);
return res.status(500).send('error');
}
},
@@ -43,6 +45,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
'/xmltv-settings',
{
schema: {
tags: ['Settings'],
response: {
200: XmlTvSettingsSchema,
500: BaseErrorSchema,
@@ -94,6 +97,7 @@ export const xmlTvSettingsRouter: RouterPluginCallback = (
'/xmltv-settings',
{
schema: {
tags: ['Settings'],
response: {
200: XmlTvSettingsSchema,
500: z.string(),

View File

@@ -0,0 +1,68 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { ArgumentsCamelCase, CommandModule } from 'yargs';
import { container } from '../container.ts';
import { setServerOptions } from '../globals.ts';
import { Server } from '../Server.ts';
import { TruthyQueryParam } from '../types/schemas.ts';
import { isNonEmptyString, isProduction } from '../util/index.ts';
import { getTunarrVersion } from '../util/version.ts';
import { ServerArgsType } from './RunServerCommand.ts';
import { GlobalArgsType } from './types.ts';
type GenerateOpenApiCommandArgs = ServerArgsType & {
apiVersion: string;
};
export const GenerateOpenApiCommand: CommandModule<
GlobalArgsType,
GenerateOpenApiCommandArgs
> = {
command: ['generate-openapi'],
describe: 'Generates the OpenAPI JSON definition of the Tunarr API',
builder: {
port: {
alias: 'p',
type: 'number',
desc: 'The port to run the Tunarr server on',
default: 0,
},
printRoutes: {
type: 'boolean',
default: () =>
TruthyQueryParam.catch(false).parse(
process.env['TUNARR_SERVER_PRINT_ROUTES'],
),
},
admin: {
type: 'boolean',
default: () => {
if (isNonEmptyString(process.env['TUNARR_SERVER_ADMIN_MODE'])) {
return TruthyQueryParam.catch(false).parse(
process.env['TUNARR_SERVER_ADMIN_MODE'],
);
}
return !isProduction;
},
},
apiVersion: {
type: 'string',
default: 'latest',
},
},
handler: async (args: ArgumentsCamelCase<GenerateOpenApiCommandArgs>) => {
console.log('Generating OpenAPI doc for version ' + args.apiVersion);
setServerOptions(args);
const server = container.get<Server>(Server);
await server.initAndRun();
await server.close();
const version = getTunarrVersion();
const outputDir = path.resolve(process.cwd(), '..', 'docs', 'generated');
const fileName = `tunarr-v${version}-openapi.json`;
await fs.writeFile(
path.join(outputDir, fileName),
JSON.stringify(server.getOpenApiDocument()),
);
process.exit(0);
},
};

View File

@@ -1,3 +1,4 @@
import { GenerateOpenApiCommand } from './GenerateOpenApiCommand.ts';
import { RunServerCommand } from './RunServerCommand.ts';
import { databaseCommands } from './database/databaseCommands.ts';
import { LegacyMigrateCommand } from './legacyMigrateCommand.ts';
@@ -10,4 +11,5 @@ export const commands = [
RunFixerCommand,
RunServerCommand,
databaseCommands,
GenerateOpenApiCommand,
];

View File

@@ -3,7 +3,7 @@ import { CachedImage } from '@/db/schema/CachedImage.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import axios, { AxiosHeaders, AxiosRequestConfig } from 'axios';
import crypto from 'crypto';
import { FastifyReply, FastifyRequest } from 'fastify';
import { FastifyReply } from 'fastify';
import { createWriteStream, promises as fs } from 'fs';
import { injectable } from 'inversify';
import { isString, isUndefined } from 'lodash-es';
@@ -39,14 +39,11 @@ export class CacheImageService {
* @returns
* @memberof CacheImageService
*/
async routerInterceptor(
req: FastifyRequest<{ Params: { hash: string } }>,
res: FastifyReply,
) {
async routerInterceptor(hash: string, res: FastifyReply) {
try {
const imgItem = await getDatabase()
.selectFrom('cachedImage')
.where('hash', '=', req.params.hash)
.where('hash', '=', hash)
.selectAll()
.executeTakeFirst();
@@ -60,6 +57,7 @@ export class CacheImageService {
}
}
} catch (err) {
this.logger.error(err);
return res.status(500).send('error');
}
}

View File

@@ -1,4 +1,10 @@
import { z } from 'zod';
import {
CacheSettingsSchema,
LogLevelsSchema,
LoggingSettingsSchema,
SystemSettingsSchema,
} from '../SystemSettings.js';
import { JellyfinItemFields, JellyfinItemKind } from '../jellyfin/index.js';
import {
ChannelConcatStreamModes,
@@ -6,27 +12,26 @@ import {
} from '../schemas/channelSchema.js';
import {
ChannelProgramSchema,
CondensedChannelProgrammingSchema,
CondensedContentProgramSchema,
CondensedCustomProgramSchema,
ContentProgramSchema,
CustomProgramSchema,
FlexProgramSchema,
RedirectProgramSchema,
} from '../schemas/programmingSchema.js';
import {
BackupSettingsSchema,
JellyfinServerSettingsSchema,
PlexServerSettingsSchema,
} from '../schemas/settingsSchemas.js';
import {
CacheSettingsSchema,
LoggingSettingsSchema,
LogLevelsSchema,
SystemSettingsSchema,
} from '../SystemSettings.js';
import {
RandomSlotScheduleSchema,
TimeSlotScheduleSchema,
} from './Scheduling.js';
export * from './plexSearch.js';
export * from './Scheduling.js';
export * from './plexSearch.js';
export const IdPathParamSchema = z.object({
id: z.string(),
@@ -255,6 +260,25 @@ export type ChannelSessionsResponse = z.infer<
typeof ChannelSessionsResponseSchema
>;
const CondensedChannelProgramWithNoOriginalSchema = z.discriminatedUnion(
'type',
[
CondensedContentProgramSchema,
CondensedCustomProgramSchema.extend({
program: CondensedContentProgramSchema.optional(),
}),
RedirectProgramSchema,
FlexProgramSchema,
],
);
// This is sorta hacky.
export const GetChannelProgrammingResponseSchema =
CondensedChannelProgrammingSchema.extend({
lineup: z.array(CondensedChannelProgramWithNoOriginalSchema),
programs: z.record(ContentProgramSchema),
});
export const JellyfinGetLibraryItemsQuerySchema = z.object({
offset: z.coerce.number().nonnegative().optional(),
limit: z.coerce.number().positive().optional(),