fix: rework native playback api types

This commit is contained in:
Christian Benincasa
2026-04-13 23:24:17 -04:00
parent 24af26d214
commit b483d72068
8 changed files with 103 additions and 80 deletions

File diff suppressed because one or more lines are too long

View File

@@ -83,8 +83,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.title).toBe(programTitle);
expect(result.episodeTitle).toBeUndefined();
}
@@ -101,8 +101,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.title).toBe(showTitle);
expect(result.episodeTitle).toBe(episodeTitle);
}
@@ -118,8 +118,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.seasonNumber).toBe(2);
expect(result.episodeNumber).toBe(3);
}
@@ -131,8 +131,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.seekOffsetMs).toBe(seekMs);
}
});
@@ -142,8 +142,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.seekOffsetMs).toBe(0);
}
});
@@ -154,8 +154,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.streamUrl).toBe(
`${baseUrl}/stream/channels/${channelId}/item-stream.ts?t=${itemStartedAtMs}`,
);
@@ -168,8 +168,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(item, baseUrl, channelId);
expect(result.kind).toBe('content');
if (result.kind === 'content') {
expect(result.type).toBe('content');
if (result.type === 'content') {
expect(result.thumb).toBe(iconUrl);
}
});
@@ -184,8 +184,8 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(offline, baseUrl, channelId);
expect(result.kind).toBe('flex');
if (result.kind === 'flex') {
expect(result.type).toBe('flex');
if (result.type === 'flex') {
expect(result.remainingMs).toBe(offline.streamDuration);
expect(result.itemStartedAtMs).toBe(offline.programBeginMs);
}
@@ -202,6 +202,6 @@ describe('mapLineupItemToPlaybackItem', () => {
const result = mapLineupItemToPlaybackItem(redirect, baseUrl, channelId);
expect(result.kind).toBe('flex');
expect(result.type).toBe('flex');
});
});

View File

@@ -1,6 +1,8 @@
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import type { NativePlaybackItem } from '@tunarr/types';
import { BasicIdParamSchema } from '@tunarr/types/api';
import { NativePlaybackResponseSchema } from '@tunarr/types/schemas';
import { isNil } from 'lodash-es';
import z from 'zod/v4';
import type { StreamLineupItem } from '../db/derived_types/StreamLineup.ts';
@@ -9,50 +11,6 @@ import {
isOfflineLineupItem,
} from '../db/derived_types/StreamLineup.ts';
const NativePlaybackContentItemSchema = z.object({
kind: z.literal('content'),
itemStartedAtMs: z.number().int(),
seekOffsetMs: z.number().int(),
remainingMs: z.number().int(),
programId: z.string().uuid(),
title: z.string(),
episodeTitle: z.string().optional(),
seasonNumber: z.number().int().optional(),
episodeNumber: z.number().int().optional(),
summary: z.string().optional(),
thumb: z.string().optional(),
streamUrl: z.string(),
});
const NativePlaybackFlexItemSchema = z.object({
kind: z.literal('flex'),
remainingMs: z.number().int(),
itemStartedAtMs: z.number().int(),
});
const NativePlaybackErrorItemSchema = z.object({
kind: z.literal('error'),
message: z.string(),
retryAfterMs: z.number().int(),
});
const NativePlaybackItemSchema = z.discriminatedUnion('kind', [
NativePlaybackContentItemSchema,
NativePlaybackFlexItemSchema,
NativePlaybackErrorItemSchema,
]);
const NativePlaybackResponseSchema = z.object({
channelId: z.string().uuid(),
channelNumber: z.number().int(),
channelName: z.string(),
serverTimeMs: z.number().int(),
current: NativePlaybackItemSchema,
next: NativePlaybackItemSchema.optional(),
});
type NativePlaybackItem = z.infer<typeof NativePlaybackItemSchema>;
function buildStreamUrl(
baseUrl: string,
channelId: string,
@@ -71,7 +29,7 @@ export function mapLineupItemToPlaybackItem(
if (isOfflineLineupItem(lineupItem)) {
return {
kind: 'flex',
type: 'flex',
remainingMs,
itemStartedAtMs,
};
@@ -80,7 +38,7 @@ export function mapLineupItemToPlaybackItem(
if (isContentBackedLineupItem(lineupItem)) {
const program = lineupItem.program;
return {
kind: 'content',
type: 'content',
itemStartedAtMs,
seekOffsetMs: lineupItem.startOffset ?? 0,
remainingMs,
@@ -99,7 +57,7 @@ export function mapLineupItemToPlaybackItem(
// error or redirect items fall through as flex
return {
kind: 'flex',
type: 'flex',
remainingMs: lineupItem.streamDuration,
itemStartedAtMs: lineupItem.programBeginMs,
};
@@ -167,7 +125,7 @@ export const nativePlaybackApi: RouterPluginAsyncCallback = async (fastify) => {
channelName: channel.name,
serverTimeMs: now,
current: {
kind: 'error',
type: 'error',
message:
currentResult.error.message ??
'Unable to determine current program',

View File

@@ -0,0 +1,22 @@
import type z from 'zod/v4';
import type {
NativePlaybackContentItemSchema,
NativePlaybackErrorItemSchema,
NativePlaybackFlexItemSchema,
NativePlaybackItemSchema,
NativePlaybackResponseSchema,
} from './schemas/nativePlaybackSchemas.js';
export type NativePlaybackContentItem = z.infer<
typeof NativePlaybackContentItemSchema
>;
export type NativePlaybackFlexItem = z.infer<
typeof NativePlaybackFlexItemSchema
>;
export type NativePlaybackErrorItem = z.infer<
typeof NativePlaybackErrorItemSchema
>;
export type NativePlaybackItem = z.infer<typeof NativePlaybackItemSchema>;
export type NativePlaybackResponse = z.infer<
typeof NativePlaybackResponseSchema
>;

View File

@@ -10,6 +10,7 @@ export * from './HdhrSettings.js';
export * from './LanguagePreferences.js';
export * from './MediaSourceSettings.js';
export * from './misc.js';
export * from './NativePlayback.js';
export * from './Program.js';
export * from './Subtitles.js';
export * from './SystemSettings.js';

View File

@@ -17,3 +17,4 @@ export * from './transcodeConfigSchemas.js';
export * from './customShowsSchema.js';
export * from './fillerSchema.js';
export * from './guideApiSchemas.js';
export * from './nativePlaybackSchemas.js';

View File

@@ -0,0 +1,45 @@
import z from 'zod/v4';
const NativePlaybackTimingSchema = z.object({
itemStartedAtMs: z.number().int(),
remainingMs: z.number().int(),
});
export const NativePlaybackContentItemSchema =
NativePlaybackTimingSchema.extend({
type: z.literal('content'),
seekOffsetMs: z.number().int(),
programId: z.string().uuid(),
title: z.string(),
episodeTitle: z.string().optional(),
seasonNumber: z.number().int().optional(),
episodeNumber: z.number().int().optional(),
summary: z.string().optional(),
thumb: z.string().optional(),
streamUrl: z.string(),
});
export const NativePlaybackFlexItemSchema = NativePlaybackTimingSchema.extend({
type: z.literal('flex'),
});
export const NativePlaybackErrorItemSchema = z.object({
type: z.literal('error'),
message: z.string(),
retryAfterMs: z.number().int(),
});
export const NativePlaybackItemSchema = z.discriminatedUnion('type', [
NativePlaybackContentItemSchema,
NativePlaybackFlexItemSchema,
NativePlaybackErrorItemSchema,
]);
export const NativePlaybackResponseSchema = z.object({
channelId: z.string().uuid(),
channelNumber: z.number().int(),
channelName: z.string(),
serverTimeMs: z.number().int(),
current: NativePlaybackItemSchema,
next: NativePlaybackItemSchema.optional(),
});

View File

@@ -5943,10 +5943,10 @@ export type GetApiChannelsByIdNativePlaybackResponses = {
channelName: string;
serverTimeMs: number;
current: {
kind: 'content';
itemStartedAtMs: number;
seekOffsetMs: number;
remainingMs: number;
type: 'content';
seekOffsetMs: number;
programId: string;
title: string;
episodeTitle?: string;
@@ -5956,19 +5956,19 @@ export type GetApiChannelsByIdNativePlaybackResponses = {
thumb?: string;
streamUrl: string;
} | {
kind: 'flex';
remainingMs: number;
itemStartedAtMs: number;
remainingMs: number;
type: 'flex';
} | {
kind: 'error';
type: 'error';
message: string;
retryAfterMs: number;
};
next?: {
kind: 'content';
itemStartedAtMs: number;
seekOffsetMs: number;
remainingMs: number;
type: 'content';
seekOffsetMs: number;
programId: string;
title: string;
episodeTitle?: string;
@@ -5978,11 +5978,11 @@ export type GetApiChannelsByIdNativePlaybackResponses = {
thumb?: string;
streamUrl: string;
} | {
kind: 'flex';
remainingMs: number;
itemStartedAtMs: number;
remainingMs: number;
type: 'flex';
} | {
kind: 'error';
type: 'error';
message: string;
retryAfterMs: number;
};