feat(ui): add channel summary page (#1190)

This commit is contained in:
Christian Benincasa
2025-07-10 11:06:50 -04:00
committed by GitHub
parent d9f5e126ec
commit 1f2e5eb7c9
43 changed files with 2236 additions and 229 deletions

14
pnpm-lock.yaml generated
View File

@@ -545,6 +545,9 @@ importers:
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-virtualized-auto-sizer:
specifier: ^1.0.26
version: 1.0.26(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-window:
specifier: ^1.8.9
version: 1.8.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -6119,6 +6122,12 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react-virtualized-auto-sizer@1.0.26:
resolution: {integrity: sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-window@1.8.9:
resolution: {integrity: sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==}
engines: {node: '>8.0.0'}
@@ -13374,6 +13383,11 @@ snapshots:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-virtualized-auto-sizer@1.0.26(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-window@1.8.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
'@babel/runtime': 7.23.9

View File

@@ -11,6 +11,7 @@ import {
BasicIdParamSchema,
BasicPagingSchema,
GetChannelProgrammingResponseSchema,
PagedResult,
TimeSlotScheduleResult,
TimeSlotScheduleSchema,
UpdateChannelProgrammingRequestSchema,
@@ -20,14 +21,19 @@ import {
ChannelSchema,
CondensedChannelProgrammingSchema,
ContentProgramSchema,
ContentProgramTypeSchema,
CreateChannelRequestSchema,
MusicArtistContentProgramSchema,
SaveableChannelSchema,
TranscodeConfigSchema,
TvGuideProgramSchema,
TvShowContentProgramSchema,
} from '@tunarr/types/schemas';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration.js';
import {
groupBy,
head,
isEmpty,
isNil,
isNull,
isUndefined,
@@ -41,6 +47,7 @@ import { dbTranscodeConfigToApiSchema } from '../db/converters/transcodeConfigCo
import type { SessionType } from '../stream/Session.ts';
import type { ChannelAndLineup } from '../types/internal.ts';
import { Result } from '../types/result.ts';
import { PagingParams } from '../types/schemas.ts';
dayjs.extend(duration);
@@ -332,6 +339,10 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
{
schema: {
params: BasicIdParamSchema,
querystring: z.object({
...PagingParams.shape,
type: ContentProgramTypeSchema.optional(),
}),
tags: ['Channels'],
response: {
200: z.array(ContentProgramSchema).readonly(),
@@ -340,35 +351,104 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
},
},
async (req, res) => {
try {
const channel = await req.serverCtx.channelDB.getChannelAndPrograms(
req.params.id,
);
const programs = await req.serverCtx.channelDB.getChannelPrograms(
req.params.id,
{
offset: req.query.offset,
limit: req.query.limit,
},
req.query.type,
);
if (!isNil(channel)) {
const externalIds =
await req.serverCtx.channelDB.getChannelProgramExternalIds(
channel.uuid,
);
const externalIdsByProgramId = groupBy(externalIds, 'programUuid');
return res.send(
map(channel.programs, (program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
externalIdsByProgramId[program.uuid] ?? [],
),
),
);
} else {
return res.status(404).send();
}
} catch (err) {
logger.error(err, req.routeOptions.url);
return res.status(500).send();
}
return res.send(
programs.map((program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
program.externalIds ?? [],
),
),
);
},
);
fastify.get(
'/channels/:id/shows',
{
schema: {
params: BasicIdParamSchema,
querystring: PagingParams,
response: {
200: PagedResult(z.array(TvShowContentProgramSchema)),
},
},
},
async (req, res) => {
const { total, results: shows } =
await req.serverCtx.channelDB.getChannelTvShows(
req.params.id,
req.query,
);
return res.send({
total,
result: shows.map((show) =>
req.serverCtx.programConverter.tvShowDaoToDto(show),
),
});
},
);
fastify.get(
'/channels/:id/artists',
{
schema: {
params: BasicIdParamSchema,
querystring: PagingParams,
response: {
200: PagedResult(z.array(MusicArtistContentProgramSchema)),
},
},
},
async (req, res) => {
const { total, results: shows } =
await req.serverCtx.channelDB.getChannelMusicArtists(
req.params.id,
req.query,
);
return res.send({
total,
result: shows.map((show) =>
req.serverCtx.programConverter.musicArtistDaoToDto(show),
),
});
},
);
// fastify.get(
// '/channels/:id/shows',
// {
// schema: {
// params: BasicIdParamSchema,
// querystring: PagingParams,
// response: {
// 200: z.array(ContentProgramParentSchema),
// },
// },
// },
// async (req, res) => {
// const shows = await req.serverCtx.channelDB.getChannelTvShows(
// req.params.id,
// );
// return res.send(
// shows.map((show) =>
// req.serverCtx.programConverter.programGroupingDaoToDto(show),
// ),
// );
// },
// );
fastify.get(
'/channels/:id/programming',
{
@@ -551,6 +631,34 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get(
'/channels/:id/now_playing',
{
schema: {
params: BasicIdParamSchema,
tags: ['Channels'],
response: {
200: TvGuideProgramSchema,
400: z.object({ error: z.string() }),
404: z.object({ error: z.string() }),
},
},
},
async (req, res) => {
const now = dayjs();
const guide = await req.serverCtx.guideService.getChannelGuide(
req.params.id,
OpenDateTimeRange.create(now, now.add(1))!,
);
if (isUndefined(guide) || isEmpty(guide.programs)) {
return res.status(404).send({ error: 'Guide data not found' });
}
return res.send(head(guide.programs));
},
);
fastify.get(
'/channels/:id/transcode_config',
{
@@ -601,15 +709,3 @@ export const channelsApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
};
// function zipWithIndex<T>(
// arr: ReadonlyArray<T>,
// ): ReadonlyArray<T & { index: number }> {
// return reduce(
// arr,
// (prev, curr, i) => {
// return [...prev, { ...curr, index: i }];
// },
// [],
// );
// }

View File

@@ -57,14 +57,15 @@ const thumbOptsSchema = z.object({
const ExternalMetadataQuerySchema = z.object({
id: externalIdSchema,
asset: z.union([z.literal('thumb'), z.literal('external-link')]),
mode: z.union([z.literal('json'), z.literal('redirect'), z.literal('proxy')]),
asset: z.enum(['thumb', 'external-link', 'image']),
mode: z.enum(['json', 'redirect', 'proxy']),
cache: TruthyQueryParam.optional().default(true),
thumbOptions: z
.string()
.transform((s) => JSON.parse(s) as unknown)
.pipe(thumbOptsSchema)
.optional(),
imageType: z.enum(['poster', 'background']).default('poster'),
});
type ExternalMetadataQuery = z.infer<typeof ExternalMetadataQuerySchema>;
@@ -191,13 +192,15 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
return null;
}
if (query.asset === 'thumb') {
if (query.asset === 'thumb' || query.asset === 'image') {
return plexApi.getThumbUrl({
itemKey: query.id.externalItemId,
width: query.thumbOptions?.width,
height: query.thumbOptions?.height,
upscale: '1',
imageType: query.imageType,
});
} else if (query.asset === 'external-link') {
}
return null;
@@ -213,14 +216,10 @@ export const metadataApiRouter: RouterPluginAsyncCallback = async (fastify) => {
return null;
}
if (query.asset === 'thumb') {
if (query.asset === 'thumb' || query.asset === 'image') {
return jellyfinClient.getThumbUrl(query.id.externalItemId);
// return jellyfinClient.getThumbUrl({
// itemKey: query.id.externalItemId,
// width: query.thumbOptions?.width,
// height: query.thumbOptions?.height,
// upscale: '1',
// });
} else if (query.asset === 'external-link') {
return jellyfinClient.getExternalUrl(query.id.externalItemId);
}
return null;

View File

@@ -4,11 +4,11 @@ import { ProgramType } from '@/db/schema/Program.js';
import { ProgramGroupingType } from '@/db/schema/ProgramGrouping.js';
import { JellyfinApiClient } from '@/external/jellyfin/JellyfinApiClient.js';
import { PlexApiClient } from '@/external/plex/PlexApiClient.js';
import { TruthyQueryParam } from '@/types/schemas.js';
import { PagingParams, TruthyQueryParam } from '@/types/schemas.js';
import type { RouterPluginAsyncCallback } from '@/types/serverType.js';
import { ifDefined, isNonEmptyString } from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { BasicIdParamSchema } from '@tunarr/types/api';
import { BasicIdParamSchema, ProgramChildrenResult } from '@tunarr/types/api';
import { ContentProgramSchema } from '@tunarr/types/schemas';
import axios, { AxiosHeaders, isAxiosError } from 'axios';
import type { HttpHeader } from 'fastify/types/utils.js';
@@ -93,6 +93,76 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
},
);
fastify.get(
'/programs/:id/children',
{
schema: {
tags: ['Programs'],
params: BasicIdParamSchema,
querystring: PagingParams,
response: {
200: ProgramChildrenResult,
404: z.void(),
},
},
},
async (req, res) => {
const grouping = await req.serverCtx.programDB.getProgramGrouping(
req.params.id,
);
if (!grouping) {
return res.status(404).send();
}
if (grouping.type === 'album' || grouping.type === 'season') {
const { total, results } = await req.serverCtx.programDB.getChildren(
req.params.id,
grouping.type,
req.query,
);
const result = results.map((program) =>
req.serverCtx.programConverter.programDaoToContentProgram(
program,
program.externalIds,
),
);
return res.send({
total,
result: {
type: grouping.type === 'album' ? 'track' : 'episode',
programs: result,
},
});
} else if (grouping.type === 'artist') {
const { total, results } = await req.serverCtx.programDB.getChildren(
req.params.id,
grouping.type,
req.query,
);
const result = results.map((program) =>
req.serverCtx.programConverter.programGroupingDaoToDto(program),
);
return res.send({ total, result: { type: 'album', programs: result } });
} else if (grouping.type === 'show') {
const { total, results } = await req.serverCtx.programDB.getChildren(
req.params.id,
grouping.type,
req.query,
);
const result = results.map((program) =>
req.serverCtx.programConverter.programGroupingDaoToDto(program),
);
return res.send({
total,
result: { type: 'season', programs: result },
});
}
return res.status(400).send();
},
);
// Image proxy for a program based on its source. Only works for persisted programs
fastify.get(
'/programs/:id/thumb',
@@ -185,7 +255,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
albumExternalIds,
(ref) =>
ref.sourceType === program.sourceType &&
ref.externalSourceId === program.externalSourceId,
(ref.mediaSourceId === program.mediaSourceId ||
ref.externalSourceId === program.externalSourceId),
),
(ref) => {
keyToUse = ref.externalKey;
@@ -204,7 +275,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
showExternalIds,
(ref) =>
ref.sourceType === program.sourceType &&
ref.externalSourceId === program.externalSourceId,
(ref.mediaSourceId === program.mediaSourceId ||
ref.externalSourceId === program.externalSourceId),
),
(ref) => {
keyToUse = ref.externalKey;
@@ -216,17 +288,18 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(500).send();
}
switch (program.sourceType) {
switch (mediaSource.type) {
case ProgramSourceType.PLEX: {
return handleResult(
mediaSource,
PlexApiClient.getThumbUrl({
PlexApiClient.getImageUrl({
uri: mediaSource.uri,
itemKey: keyToUse,
accessToken: mediaSource.accessToken,
height: req.query.height,
width: req.query.width,
upscale: req.query.upscale.toString(),
imageType: 'poster',
}),
);
}
@@ -261,27 +334,30 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.status(500).send();
}
const mediaSource = await req.serverCtx.mediaSourceDB.getByExternalId(
// This was asserted above
source.sourceType as 'plex' | 'jellyfin',
source.externalSourceId,
);
const mediaSource = await (isNonEmptyString(source.mediaSourceId)
? req.serverCtx.mediaSourceDB.getById(source.mediaSourceId)
: req.serverCtx.mediaSourceDB.getByExternalId(
// This was asserted above
source.sourceType as 'plex' | 'jellyfin',
source.externalSourceId,
));
if (isNil(mediaSource)) {
return res.status(404).send();
}
switch (source.sourceType) {
switch (mediaSource.type) {
case ProgramExternalIdType.PLEX:
return handleResult(
mediaSource,
PlexApiClient.getThumbUrl({
PlexApiClient.getImageUrl({
uri: mediaSource.uri,
itemKey: source.externalKey,
accessToken: mediaSource.accessToken,
height: req.query.height,
width: req.query.width,
upscale: req.query.upscale.toString(),
imageType: 'poster',
}),
);
case ProgramExternalIdType.JELLYFIN:
@@ -377,6 +453,8 @@ export const programmingApi: RouterPluginAsyncCallback = async (fastify) => {
return res.redirect(url, 302).send();
}
default:
return res.status(405).send();
}
},
);

View File

@@ -7,7 +7,7 @@ import { ChannelNotFoundError } from '@/types/errors.js';
import { KEYS } from '@/types/inject.js';
import { typedProperty } from '@/types/path.js';
import { Result } from '@/types/result.js';
import { Maybe } from '@/types/util.js';
import { Maybe, PagedResult } from '@/types/util.js';
import { Timer } from '@/util/Timer.js';
import { asyncPool } from '@/util/asyncPool.js';
import dayjs from '@/util/dayjs.js';
@@ -27,6 +27,7 @@ import {
Watermark,
} from '@tunarr/types';
import { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
import { ContentProgramType } from '@tunarr/types/schemas';
import { inject, injectable, interfaces } from 'inversify';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/sqlite';
@@ -37,6 +38,7 @@ import {
filter,
forEach,
groupBy,
identity,
isEmpty,
isNil,
isNull,
@@ -94,11 +96,15 @@ import {
AllProgramGroupingFields,
MinimalProgramGroupingFields,
withFallbackPrograms,
withMusicArtistAlbums,
withProgramExternalIds,
withProgramGroupingExternalIds,
withPrograms,
withTrackAlbum,
withTrackArtist,
withTvSeason,
withTvShow,
withTvShowSeasons,
} from './programQueryHelpers.ts';
import {
ChannelUpdate,
@@ -107,7 +113,8 @@ import {
NewChannelProgram,
Channel as RawChannel,
} from './schema/Channel.ts';
import { programExternalIdString } from './schema/Program.ts';
import { programExternalIdString, ProgramType } from './schema/Program.ts';
import { ProgramGroupingType } from './schema/ProgramGrouping.ts';
import {
ChannelSubtitlePreferences,
NewChannelSubtitlePreference,
@@ -116,6 +123,9 @@ import { DB } from './schema/db.ts';
import {
ChannelWithPrograms,
ChannelWithRelations,
MusicArtistWithExternalIds,
ProgramWithRelations,
TvShowWithExternalIds,
} from './schema/derivedTypes.js';
// We use this to chunk super huge channel / program relation updates because
@@ -264,6 +274,7 @@ export class ChannelDB implements IChannelDB {
getChannelAndPrograms(
uuid: string,
typeFilter?: ContentProgramType,
): Promise<ChannelWithPrograms | undefined> {
return this.db
.selectFrom('channel')
@@ -275,41 +286,168 @@ export class ChannelDB implements IChannelDB {
'channelPrograms.channelUuid',
)
.select((eb) =>
withPrograms(eb, {
joins: {
customShows: true,
tvShow: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
tvSeason: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackArtist: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackAlbum: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
withPrograms(
eb,
{
joins: {
customShows: true,
tvShow: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
tvSeason: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackArtist: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
trackAlbum: [
'programGrouping.uuid',
'programGrouping.title',
'programGrouping.summary',
'programGrouping.type',
],
},
},
}),
typeFilter
? (eb) => eb.where('program.type', '=', typeFilter)
: identity,
),
)
.groupBy('channel.uuid')
.orderBy('channel.number asc')
.executeTakeFirst();
}
async getChannelTvShows(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<TvShowWithExternalIds>> {
const baseQuery = this.db
.selectFrom('channelPrograms')
.where('channelPrograms.channelUuid', '=', id)
.innerJoin('program', 'channelPrograms.programUuid', 'program.uuid')
.innerJoin(
'programGrouping',
'program.tvShowUuid',
'programGrouping.uuid',
)
.where('program.type', '=', 'episode')
.where('program.tvShowUuid', 'is not', null)
.where('programGrouping.type', '=', ProgramGroupingType.Show);
const countPromise = baseQuery
.select((eb) =>
eb.fn.count<number>('programGrouping.uuid').distinct().as('count'),
)
.executeTakeFirstOrThrow();
const showPromise = baseQuery
.selectAll('programGrouping')
.select(withProgramGroupingExternalIds)
.select(withTvShowSeasons)
.groupBy('program.tvShowUuid')
.orderBy('programGrouping.uuid asc')
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.offset(pageParams!.offset),
)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.limit(pageParams!.limit),
)
.execute() as Promise<TvShowWithExternalIds[]>;
const [{ count }, shows] = await Promise.all([countPromise, showPromise]);
return {
total: count,
results: shows,
};
}
async getChannelMusicArtists(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<MusicArtistWithExternalIds>> {
const baseQuery = this.db
.selectFrom('channelPrograms')
.where('channelPrograms.channelUuid', '=', id)
.innerJoin('program', 'channelPrograms.programUuid', 'program.uuid')
.innerJoin(
'programGrouping',
'program.artistUuid',
'programGrouping.uuid',
)
.where('program.type', '=', ProgramType.Track)
.where('program.artistUuid', 'is not', null)
.where('programGrouping.type', '=', ProgramGroupingType.Artist);
const countPromise = baseQuery
.select((eb) =>
eb.fn.count<number>('programGrouping.uuid').distinct().as('count'),
)
.executeTakeFirstOrThrow();
const artistsPromise = baseQuery
.selectAll('programGrouping')
.select(withProgramGroupingExternalIds)
.select(withMusicArtistAlbums)
.groupBy('program.artistUuid')
.orderBy('programGrouping.uuid asc')
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.offset(pageParams!.offset),
)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.limit(pageParams!.limit),
)
.execute() as Promise<MusicArtistWithExternalIds[]>;
const [{ count }, artists] = await Promise.all([
countPromise,
artistsPromise,
]);
return {
total: count,
results: artists,
};
}
getChannelPrograms(
id: string,
pageParams?: PageParams,
typeFilter?: ContentProgramType,
): Promise<ProgramWithRelations[]> {
return (
this.db
.selectFrom('channelPrograms')
.where('channelPrograms.channelUuid', '=', id)
// .select(eb => withPrograms(eb, defaultWithProgramOptions, qb => qb.$if(!!typeFilter, b => b.where('program.type', ))))
.innerJoin('program', 'channelPrograms.programUuid', 'program.uuid')
.$if(!!typeFilter, (eb) => eb.where('program.type', '=', typeFilter!))
.selectAll('program')
.select(withProgramExternalIds)
.select(withTrackAlbum)
.select(withTrackArtist)
.select(withTvSeason)
.select(withTvShow)
.orderBy('program.uuid asc')
// TODO: Implement as cursor
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.offset(pageParams!.offset),
)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.limit(pageParams!.limit),
)
.execute()
);
}
getChannelProgramExternalIds(uuid: string) {
return this.db
.selectFrom('channelPrograms')

View File

@@ -12,7 +12,7 @@ import {
type SavePlexProgramExternalIdsTaskFactory,
} from '@/tasks/plex/SavePlexProgramExternalIdsTask.js';
import { KEYS } from '@/types/inject.js';
import { MarkNonNullable, Maybe } from '@/types/util.js';
import { MarkNonNullable, Maybe, PagedResult } from '@/types/util.js';
import { Timer } from '@/util/Timer.js';
import { devAssert } from '@/util/debug.js';
import { LoggerFactory, type Logger } from '@/util/logging/LoggerFactory.js';
@@ -73,6 +73,7 @@ import {
ProgramSourceType,
programSourceTypeFromString,
} from './custom_types/ProgramSourceType.ts';
import { PageParams } from './interfaces/IChannelDB.ts';
import {
AllProgramJoins,
ProgramUpsertFields,
@@ -110,8 +111,11 @@ import {
} from './schema/ProgramGroupingExternalId.ts';
import { DB } from './schema/db.ts';
import type {
MusicAlbumWithExternalIds,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
TvSeasonWithExternalIds,
} from './schema/derivedTypes.ts';
type ValidatedContentProgram = MarkRequired<
@@ -266,6 +270,98 @@ export class ProgramDB implements IProgramDB {
return;
}
getChildren(
parentId: string,
parentType: 'season' | 'album',
pageParams?: PageParams,
): Promise<PagedResult<ProgramWithExternalIds>>;
getChildren(
parentId: string,
parentType: 'artist',
pageParams?: PageParams,
): Promise<PagedResult<MusicAlbumWithExternalIds>>;
getChildren(
parentId: string,
parentType: 'show',
pageParams?: PageParams,
): Promise<PagedResult<TvSeasonWithExternalIds>>;
async getChildren(
parentId: string,
parentType: ProgramGroupingType,
pageParams?: PageParams,
): Promise<
PagedResult<ProgramWithExternalIds | ProgramGroupingWithExternalIds>
> {
if (parentType === 'album' || parentType === 'season') {
const baseQuery = this.db
.selectFrom('program')
.where('type', '=', parentType === 'album' ? 'track' : 'episode')
.where(
parentType === 'album' ? 'albumUuid' : 'seasonUuid',
'=',
parentId,
);
const countPromise = baseQuery
.select((eb) => eb.fn.count<number>('uuid').as('count'))
.executeTakeFirstOrThrow();
const resultPromise = baseQuery
.select(withProgramExternalIds)
.selectAll()
.orderBy('episode asc')
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.offset(pageParams!.offset),
)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.limit(pageParams!.limit),
)
.execute();
const [{ count }, results] = await Promise.all([
countPromise,
resultPromise,
]);
return {
total: count,
results,
};
} else {
const childType = parentType === 'artist' ? 'album' : 'season';
const baseQuery = this.db
.selectFrom('programGrouping')
.where('type', '=', childType)
.where(
childType === 'season' ? 'showUuid' : 'artistUuid',
'=',
parentId,
);
const [{ count }, results] = await Promise.all([
baseQuery
.select((eb) => eb.fn.count<number>('uuid').as('count'))
.executeTakeFirstOrThrow(),
baseQuery
.selectAll()
.orderBy(childType === 'season' ? 'title asc' : 'year asc')
.select(withProgramGroupingExternalIds)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.offset(pageParams!.offset),
)
.$if(!!pageParams && pageParams.limit >= 0, (eb) =>
eb.limit(pageParams!.limit),
)
.execute(),
]);
return {
total: count,
results,
};
}
}
async lookupByExternalId(eid: {
sourceType: ProgramSourceType;
externalSourceId: string;
@@ -957,6 +1053,21 @@ export class ProgramDB implements IProgramDB {
foundGroupingRatingKeys.includes(parent),
);
// const existingParents = seq.collect(
// existingParentKeys,
// (key) => existingGroupingsByKey[key],
// );
// Fix mappings if we have to...
// const existingParentsNeedingUpdate = existingParents.filter(parent => {
// if (parent.type === ProgramGroupingType.Album && parent.artistUuid !== grandparentGrouping.uuid) {
// parent.artistUuid = grandparentGrouping.uuid;
// return true;
// } else if (parent.type === ProgramGroupingType.Season && parent.showUuid !== grandparentGrouping.uuid) {
// return true;
// }
// return false;
// });
for (const parentKey of parents) {
const programIds = parentRatingKeyToProgramId[parentKey];
if (!programIds || programIds.size === 0) {
@@ -990,13 +1101,13 @@ export class ProgramDB implements IProgramDB {
if (program.type === ProgramType.Episode) {
program.seasonUuid = parentGrouping.uuid;
updatesByType[ProgramGroupingType.Season].add(program.uuid);
} else {
} else if (program.type === ProgramType.Track) {
program.albumUuid = parentGrouping.uuid;
updatesByType[ProgramGroupingType.Album].add(program.uuid);
}
});
if (parentGrouping.type === ProgramGroupingType.Show) {
if (parentGrouping.type === ProgramGroupingType.Season) {
parentGrouping.showUuid = grandparentGrouping.uuid;
} else if (parentGrouping.type === ProgramGroupingType.Album) {
parentGrouping.artistUuid = grandparentGrouping.uuid;

View File

@@ -8,8 +8,14 @@ import { seq } from '@tunarr/shared/util';
import {
ChannelProgram,
ContentProgram,
ContentProgramParent,
ExternalId,
FlexProgram,
MusicAlbumContentProgram,
MusicArtistContentProgram,
RedirectProgram,
TvSeasonContentProgram,
TvShowContentProgram,
} from '@tunarr/types';
import {
isValidMultiExternalIdType,
@@ -32,7 +38,12 @@ import { DB } from '../schema/db.ts';
import type {
ChannelWithPrograms,
ChannelWithRelations,
GeneralizedProgramGroupingWithExternalIds,
MusicAlbumWithExternalIds,
MusicArtistWithExternalIds,
ProgramWithRelations,
TvSeasonWithExternalIds,
TvShowWithExternalIds,
} from '../schema/derivedTypes.ts';
/**
@@ -99,7 +110,7 @@ export class ProgramConverter {
programDaoToContentProgram(
program: ProgramWithRelations,
externalIds: MinimalProgramExternalId[],
externalIds: MinimalProgramExternalId[] = program.externalIds ?? [],
): MarkRequired<ContentProgram, 'id'> {
let extraFields: Partial<ContentProgram> = {};
if (program.type === ProgramType.Episode) {
@@ -115,6 +126,7 @@ export class ProgramConverter {
episodeNumber: nullToUndefined(program.episode),
title: program.title,
parent: {
type: 'season',
id: nullToUndefined(program.tvSeason?.uuid ?? program.seasonUuid),
index: nullToUndefined(program.tvSeason?.index),
title: nullToUndefined(program.tvSeason?.title ?? program.showTitle),
@@ -130,6 +142,7 @@ export class ProgramConverter {
),
},
grandparent: {
type: 'show',
id: nullToUndefined(program.tvShow?.uuid ?? program.tvShowUuid),
index: nullToUndefined(program.tvShow?.index),
title: nullToUndefined(program.tvShow?.title),
@@ -149,6 +162,7 @@ export class ProgramConverter {
} else if (program.type === ProgramType.Track.toString()) {
extraFields = {
parent: {
type: 'album',
id: nullToUndefined(program.trackAlbum?.uuid ?? program.albumUuid),
index: nullToUndefined(program.trackAlbum?.index),
title: nullToUndefined(
@@ -166,6 +180,7 @@ export class ProgramConverter {
),
},
grandparent: {
type: 'artist',
id: nullToUndefined(program.trackArtist?.uuid ?? program.artistUuid),
index: nullToUndefined(program.trackArtist?.index),
title: nullToUndefined(program.trackArtist?.title),
@@ -210,6 +225,91 @@ export class ProgramConverter {
};
}
programGroupingDaoToDto(program: TvShowWithExternalIds): TvShowContentProgram;
programGroupingDaoToDto(
program: TvSeasonWithExternalIds,
): TvSeasonContentProgram;
programGroupingDaoToDto(
program: MusicArtistWithExternalIds,
): MusicArtistContentProgram;
programGroupingDaoToDto(
program: MusicAlbumWithExternalIds,
): MusicAlbumContentProgram;
programGroupingDaoToDto(
program: GeneralizedProgramGroupingWithExternalIds,
): ContentProgramParent {
const base = {
type: program.type,
id: program.uuid,
title: program.title,
year: nullToUndefined(program.year),
index: nullToUndefined(program.index),
summary: nullToUndefined(program.summary),
externalIds: seq.collect(program.externalIds, (eid) => {
if (
isValidMultiExternalIdType(eid.sourceType) &&
isNonEmptyString(eid.externalSourceId)
) {
return {
type: 'multi',
source: eid.sourceType,
sourceId: eid.mediaSourceId ?? eid.externalSourceId,
id: eid.externalKey,
} satisfies ExternalId;
} else if (isValidSingleExternalIdType(eid.sourceType)) {
return {
type: 'single',
source: eid.sourceType,
id: eid.externalKey,
} satisfies ExternalId;
}
return;
}),
};
if (program.type === 'show') {
(base as TvShowContentProgram).seasons = program.seasons?.map((season) =>
this.programGroupingDaoToDto(season),
);
return base;
}
return base;
}
tvShowDaoToDto(program: TvShowWithExternalIds): TvShowContentProgram {
const base = this.programGroupingDaoToDto(program);
return {
...base,
type: 'show',
seasons: program.seasons?.map(
(season) =>
({
...this.programGroupingDaoToDto(season),
type: 'season',
}) satisfies TvSeasonContentProgram,
),
} satisfies TvShowContentProgram;
}
musicArtistDaoToDto(
program: MusicArtistWithExternalIds,
): MusicArtistContentProgram {
const base = this.programGroupingDaoToDto(program);
return {
...base,
type: 'artist',
albums: program.albums?.map(
(season) =>
({
...this.programGroupingDaoToDto(season),
type: 'album',
}) satisfies MusicAlbumContentProgram,
),
} satisfies MusicArtistContentProgram;
}
offlineLineupItemToProgram(
channel: ChannelWithRelations,
program: OfflineItem,

View File

@@ -135,7 +135,7 @@ export class ProgramGroupingMinter {
updatedAt: now,
index: null,
title: item.grandparent.title ?? '',
summary: null,
summary: item.grandparent.summary,
icon: null,
artistUuid: null,
showUuid: null,
@@ -155,13 +155,13 @@ export class ProgramGroupingMinter {
uuid: v4(),
type:
item.subtype === 'episode'
? ProgramGroupingType.Show
: ProgramGroupingType.Artist,
? ProgramGroupingType.Season
: ProgramGroupingType.Album,
createdAt: now,
updatedAt: now,
index: item.parent.index,
title: item.parent.title ?? '',
summary: null,
summary: item.parent.summary,
icon: null,
artistUuid: null,
showUuid: null,

View File

@@ -10,15 +10,24 @@ import type { ProgramExternalId } from '@/db/schema/ProgramExternalId.js';
import type {
ChannelWithPrograms,
ChannelWithRelations,
MusicArtistWithExternalIds,
ProgramWithRelations,
TvShowWithExternalIds,
} from '@/db/schema/derivedTypes.js';
import type { ChannelAndLineup } from '@/types/internal.js';
import type { MarkNullable, Maybe, Nullable } from '@/types/util.js';
import type {
MarkNullable,
Maybe,
Nullable,
PagedResult,
} from '@/types/util.js';
import type {
ChannelProgramming,
CondensedChannelProgramming,
SaveableChannel,
} from '@tunarr/types';
import type { UpdateChannelProgrammingRequest } from '@tunarr/types/api';
import type { ContentProgramType } from '@tunarr/types/schemas';
import type { MarkOptional, MarkRequired } from 'ts-essentials';
import type { ChannelSubtitlePreferences } from '../schema/SubtitlePreferences.ts';
@@ -43,7 +52,26 @@ export interface IChannelDB {
getAllChannels(pageParams?: PageParams): Promise<Channel[]>;
getChannelAndPrograms(uuid: string): Promise<ChannelWithPrograms | undefined>;
getChannelAndPrograms(
uuid: string,
typeFilter?: ContentProgramType,
): Promise<ChannelWithPrograms | undefined>;
getChannelTvShows(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<TvShowWithExternalIds>>;
getChannelMusicArtists(
id: string,
pageParams?: PageParams,
): Promise<PagedResult<MusicArtistWithExternalIds>>;
getChannelPrograms(
id: string,
pageParams?: PageParams,
typeFilter?: ContentProgramType,
): Promise<ProgramWithRelations[]>;
getChannelProgramExternalIds(uuid: string): Promise<ProgramExternalId[]>;

View File

@@ -9,13 +9,17 @@ import type {
} from '@/db/schema/ProgramExternalId.js';
import type { ProgramExternalIdSourceType } from '@/db/schema/base.js';
import type {
MusicAlbumWithExternalIds,
ProgramGroupingWithExternalIds,
ProgramWithExternalIds,
ProgramWithRelations,
TvSeasonWithExternalIds,
} from '@/db/schema/derivedTypes.js';
import type { Maybe } from '@/types/util.js';
import type { Maybe, PagedResult } from '@/types/util.js';
import type { ChannelProgram, ContentProgram } from '@tunarr/types';
import type { MarkOptional } from 'ts-essentials';
import type { ProgramGroupingType } from '../schema/ProgramGrouping.ts';
import type { PageParams } from './IChannelDB.ts';
export interface IProgramDB {
getProgramById(id: string): Promise<Maybe<ProgramWithExternalIds>>;
@@ -42,6 +46,29 @@ export interface IProgramDB {
programId: string,
): Promise<Maybe<ProgramGroupingWithExternalIds>>;
getChildren(
parentId: string,
parentType: 'season' | 'album',
pageParams?: PageParams,
): Promise<PagedResult<ProgramWithExternalIds>>;
getChildren(
parentId: string,
parentType: 'artist',
pageParams?: PageParams,
): Promise<PagedResult<MusicAlbumWithExternalIds>>;
getChildren(
parentId: string,
parentType: 'show',
pageParams?: PageParams,
): Promise<PagedResult<TvSeasonWithExternalIds>>;
getChildren(
parentId: string,
parentType: ProgramGroupingType,
pageParams?: PageParams,
): Promise<
PagedResult<ProgramWithExternalIds | ProgramGroupingWithExternalIds>
>;
lookupByExternalId(eid: {
sourceType: ProgramSourceType;
externalSourceId: string;

View File

@@ -3,18 +3,26 @@ import type {
CaseWhenBuilder,
ExpressionBuilder,
Kysely,
Selection,
SelectQueryBuilder,
UpdateQueryBuilder,
UpdateResult,
} from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite';
import { isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
import { identity, isBoolean, isEmpty, keys, merge, reduce } from 'lodash-es';
import type { DeepPartial, DeepRequired, StrictExclude } from 'ts-essentials';
import type { FillerShowTable as RawFillerShow } from './schema/FillerShow.js';
import type { ProgramTable as RawProgram } from './schema/Program.ts';
import type {
ProgramDao,
ProgramTable as RawProgram,
} from './schema/Program.ts';
import { ProgramType } from './schema/Program.ts';
import type { ProgramExternalId } from './schema/ProgramExternalId.ts';
import { ProgramExternalIdFieldsWithAlias } from './schema/ProgramExternalId.ts';
import type { ProgramGroupingTable as RawProgramGrouping } from './schema/ProgramGrouping.ts';
import {
ProgramGroupingType,
type ProgramGroupingTable as RawProgramGrouping,
} from './schema/ProgramGrouping.ts';
import type { ProgramGroupingExternalId } from './schema/ProgramGroupingExternalId.ts';
import { ProgramGroupingExternalIdFieldsWithAlias } from './schema/ProgramGroupingExternalId.ts';
import type { DB } from './schema/db.ts';
@@ -97,6 +105,46 @@ export function withTvSeason(
).as('tvSeason');
}
export function withTvShowSeasons(
eb: ExpressionBuilder<DB, 'programGrouping'>,
fields: ProgramGroupingFields<'season'> = AllProgramGroupingFieldsAliased(
'season',
),
includeExternalIds: boolean = false,
) {
return jsonArrayFrom(
eb
.selectFrom('programGrouping as season')
.select(fields)
.$if(includeExternalIds, (qb) =>
qb.select((eb) => withProgramGroupingExternalIds(eb)),
)
.whereRef('programGrouping.uuid', '=', 'season.showUuid')
.where('season.type', '=', ProgramGroupingType.Season)
.orderBy(['season.index asc', 'season.uuid asc']),
).as('seasons');
}
export function withMusicArtistAlbums(
eb: ExpressionBuilder<DB, 'programGrouping'>,
fields: ProgramGroupingFields<'album'> = AllProgramGroupingFieldsAliased(
'album',
),
includeExternalIds: boolean = false,
) {
return jsonArrayFrom(
eb
.selectFrom('programGrouping as album')
.select(fields)
.$if(includeExternalIds, (qb) =>
qb.select((eb) => withProgramGroupingExternalIds(eb)),
)
.whereRef('programGrouping.uuid', '=', 'album.showUuid')
.where('album.type', '=', ProgramGroupingType.Album)
.orderBy(['album.index asc', 'album.uuid asc']),
).as('albums');
}
export function withTrackArtist(
eb: ExpressionBuilder<DB, 'program'>,
fields: ProgramGroupingFields = AllProgramGroupingFields,
@@ -183,6 +231,7 @@ export function withProgramGroupingExternalIds(
'externalKey',
'sourceType',
'externalSourceId',
'mediaSourceId',
],
) {
return jsonArrayFrom(
@@ -302,28 +351,39 @@ export type WithProgramsOptions = {
fields?: ProgramFields;
};
const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
export const defaultWithProgramOptions: DeepRequired<WithProgramsOptions> = {
joins: defaultProgramJoins,
fields: AllProgramFields,
};
type BaseWithProgramsAvailableTables =
| 'channel'
| 'channelPrograms'
| 'channelFallback'
| 'fillerShowContent'
| 'fillerShow'
| 'customShow'
| 'customShowContent'
| 'programExternalId';
function baseWithProgramsExpressionBuilder(
eb: ExpressionBuilder<
DB,
| 'channel'
| 'channelPrograms'
| 'channelFallback'
| 'fillerShowContent'
| 'fillerShow'
| 'customShow'
| 'customShowContent'
| 'programExternalId'
>,
eb: ExpressionBuilder<DB, BaseWithProgramsAvailableTables>,
opts: DeepRequired<WithProgramsOptions>,
builderFunc: (
qb: SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
>,
) => SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
> = identity,
) {
return eb
.selectFrom('program')
.select(opts.fields)
const builder = eb.selectFrom('program').select(opts.fields);
return builderFunc(builder)
.$if(!!opts.joins.trackAlbum, (qb) =>
qb.select((eb) =>
withTrackAlbum(
@@ -344,15 +404,18 @@ function baseWithProgramsExpressionBuilder(
export function selectProgramsBuilder(
db: Kysely<DB>,
optOverides: DeepPartial<WithProgramsOptions> = defaultWithProgramOptions,
builderFunc: (
qb: SelectQueryBuilder<DB, 'program', Selection<DB, 'program', ProgramDao>>,
) => SelectQueryBuilder<DB, 'program', ProgramDao> = identity,
) {
const opts: DeepRequired<WithProgramsOptions> = merge(
{},
defaultWithProgramOptions,
optOverides,
);
return db
.selectFrom('program')
.select(opts.fields)
const builder = db.selectFrom('program').select(opts.fields);
return builderFunc(builder)
.$if(!!opts.joins.trackAlbum, (qb) =>
qb.select((eb) =>
withTrackAlbum(
@@ -372,10 +435,21 @@ export function selectProgramsBuilder(
export function withPrograms(
eb: ExpressionBuilder<DB, 'channel' | 'channelPrograms'>,
options: WithProgramsOptions = defaultWithProgramOptions,
builderFunc: (
qb: SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
>,
) => SelectQueryBuilder<
DB,
BaseWithProgramsAvailableTables | 'program',
ProgramDao
> = identity,
) {
const mergedOpts = merge({}, defaultWithProgramOptions, options);
return jsonArrayFrom(
baseWithProgramsExpressionBuilder(eb, mergedOpts).innerJoin(
baseWithProgramsExpressionBuilder(eb, mergedOpts, builderFunc).innerJoin(
'channelPrograms',
(join) =>
join

View File

@@ -1,6 +1,6 @@
import type { TranscodeConfig } from '@/db/schema/TranscodeConfig.js';
import type { MarkNonNullable } from '@/types/util.js';
import type { DeepNullable, MarkRequired } from 'ts-essentials';
import type { DeepNullable, MarkRequired, StrictOmit } from 'ts-essentials';
import type { Channel, ChannelFillerShow } from './Channel.ts';
import type { FillerShow } from './FillerShow.ts';
import type { ProgramDao } from './Program.ts';
@@ -61,3 +61,38 @@ export type ProgramWithExternalIds = ProgramDao & {
export type ProgramGroupingWithExternalIds = ProgramGrouping & {
externalIds: ProgramGroupingExternalId[];
};
type SpecificSubtype<BaseType, Value extends BaseType['type']> = StrictOmit<
BaseType,
'type'
> & { type: Value };
export type TvSeasonWithExternalIds = SpecificSubtype<
ProgramGroupingWithExternalIds,
'season'
>;
export type TvShowWithExternalIds = SpecificSubtype<
ProgramGroupingWithExternalIds,
'show'
> & {
seasons?: TvSeasonWithExternalIds[];
};
export type MusicAlbumWithExternalIds = SpecificSubtype<
ProgramGroupingWithExternalIds,
'album'
>;
export type MusicArtistWithExternalIds = SpecificSubtype<
ProgramGroupingWithExternalIds,
'artist'
> & {
albums?: MusicAlbumWithExternalIds[];
};
export type GeneralizedProgramGroupingWithExternalIds =
| TvShowWithExternalIds
| TvSeasonWithExternalIds
| MusicAlbumWithExternalIds
| MusicArtistWithExternalIds;

View File

@@ -355,6 +355,10 @@ export class JellyfinApiClient extends BaseApiClient {
return `${this.options.url}/Items/${id}/Images/Primary`;
}
getExternalUrl(id: string) {
return `${this.options.url}/web/#/details?id=${id}`;
}
async recordPlaybackStart(itemId: string, deviceId: string) {
return this.doPost({
url: '/Sessions/Playing',

View File

@@ -29,6 +29,7 @@ import {
map,
reject,
} from 'lodash-es';
import { match } from 'ts-pattern';
import type {
PlexMediaContainer,
PlexMediaContainerResponse,
@@ -292,14 +293,16 @@ export class PlexApiClient extends BaseApiClient {
width?: number;
height?: number;
upscale?: string;
imageType: 'poster' | 'background';
}) {
return PlexApiClient.getThumbUrl({
return PlexApiClient.getImageUrl({
uri: this.opts.url,
accessToken: this.opts.accessToken,
itemKey: opts.itemKey,
width: opts.width,
height: opts.height,
upscale: opts.upscale,
imageType: opts.imageType,
});
}
@@ -319,19 +322,32 @@ export class PlexApiClient extends BaseApiClient {
return super.preRequestValidate(req);
}
static getThumbUrl(opts: {
static getImageUrl(opts: {
uri: string;
accessToken: string;
itemKey: string;
width?: number;
height?: number;
upscale?: string;
imageType: 'poster' | 'background';
}): string {
const { uri, accessToken, itemKey, width, height, upscale } = opts;
const {
uri,
accessToken,
itemKey,
width,
height,
upscale,
imageType = 'poster',
} = opts;
const cleanKey = itemKey.replaceAll(/\/library\/metadata\//g, '');
const path = match(imageType)
.with('poster', () => 'thumb')
.with('background', () => 'art')
.exhaustive();
let thumbUrl: URL;
const key = `/library/metadata/${cleanKey}/thumb?X-Plex-Token=${accessToken}`;
const key = `/library/metadata/${cleanKey}/${path}?X-Plex-Token=${accessToken}`;
if (isUndefined(height) || isUndefined(width)) {
thumbUrl = new URL(`${uri}${key}`);
} else {

View File

@@ -340,5 +340,14 @@ export class BackfillProgramGroupings extends Fixer {
stillMissing?.count,
);
}
await this.db
.updateTable('programGrouping')
.where('programGrouping.showUuid', 'is not', null)
.where('programGrouping.type', '=', 'show')
.set({
type: ProgramGroupingType.Season,
})
.executeTakeFirst();
}
}

View File

@@ -8,3 +8,8 @@ export const TruthyQueryParam = z
z.coerce.number(),
])
.transform((value) => value === 1 || value === true || value === 'true');
export const PagingParams = z.object({
limit: z.coerce.number().min(-1).default(-1),
offset: z.coerce.number().nonnegative().default(0),
});

View File

@@ -40,3 +40,8 @@ export type MarkNotNilable<Type, Keys extends keyof Type> = MarkNonNullable<
MarkRequired<Type, Keys>,
Keys
>;
export type PagedResult<T> = {
total: number;
results: Array<T>;
};

View File

@@ -125,6 +125,8 @@ export class ApiProgramMinter {
index: plexEpisode.parentIndex,
externalKey: plexEpisode.parentRatingKey,
guids: plexEpisode.parentGuid ? [plexEpisode.parentGuid] : [],
type: 'season',
// summary:
externalIds: compact([
plexEpisode.parentRatingKey
? ({
@@ -147,6 +149,7 @@ export class ApiProgramMinter {
title: plexEpisode.grandparentTitle,
externalKey: plexEpisode.grandparentRatingKey,
guids: plexEpisode.grandparentGuid ? [plexEpisode.grandparentGuid] : [],
type: 'show',
externalIds: compact([
plexEpisode.grandparentRatingKey
? ({
@@ -196,6 +199,7 @@ export class ApiProgramMinter {
index: plexTrack.parentIndex,
externalKey: plexTrack.parentRatingKey,
guids: plexTrack.parentGuid ? [plexTrack.parentGuid] : [],
type: 'album',
year: plexTrack.parentYear,
externalIds: compact([
plexTrack.parentRatingKey
@@ -219,6 +223,7 @@ export class ApiProgramMinter {
title: plexTrack.grandparentTitle,
externalKey: plexTrack.grandparentRatingKey,
guids: plexTrack.grandparentGuid ? [plexTrack.grandparentGuid] : [],
type: 'artist',
externalIds: compact([
plexTrack.grandparentRatingKey
? ({
@@ -279,6 +284,7 @@ export class ApiProgramMinter {
.exhaustive(),
year: nullToUndefined(item.ProductionYear),
parent: {
type: item.Type === 'Episode' ? 'season' : 'album',
title: nullToUndefined(item.SeasonName ?? item.Album),
index: nullToUndefined(item.ParentIndexNumber),
externalKey: nullToUndefined(parentIdentifier),
@@ -294,6 +300,7 @@ export class ApiProgramMinter {
]),
},
grandparent: {
type: item.Type === 'Episode' ? 'show' : 'artist',
title: nullToUndefined(item.SeriesName ?? item.AlbumArtist),
externalKey:
item.SeriesId ??
@@ -348,6 +355,7 @@ export class ApiProgramMinter {
.exhaustive(),
year: nullToUndefined(item.ProductionYear),
parent: {
type: item.Type === 'Episode' ? 'season' : 'album',
title: nullToUndefined(item.SeasonName ?? item.Album),
index: nullToUndefined(item.ParentIndexNumber),
externalKey: nullToUndefined(parentIdentifier),
@@ -363,6 +371,7 @@ export class ApiProgramMinter {
]),
},
grandparent: {
type: item.Type === 'Episode' ? 'show' : 'artist',
title: nullToUndefined(item.SeriesName ?? item.AlbumArtist),
externalKey:
item.SeriesId ??
@@ -372,7 +381,7 @@ export class ApiProgramMinter {
? ({
type: 'multi',
id: grandparentIdentifier,
source: 'plex',
source: 'emby',
sourceId: server.name,
} satisfies MultiExternalId)
: null,

View File

@@ -1,4 +1,10 @@
import type z from 'zod/v4';
import type {
MusicAlbumContentProgramSchema,
MusicArtistContentProgramSchema,
TvSeasonContentProgramSchema,
TvShowContentProgramSchema,
} from './schemas/programmingSchema.js';
import {
type BaseProgramSchema,
type ChannelProgramSchema,
@@ -29,6 +35,20 @@ export type ContentProgram = z.infer<typeof ContentProgramSchema>;
export type ContentProgramParent = z.infer<typeof ContentProgramParentSchema>;
export type TvShowContentProgram = z.infer<typeof TvShowContentProgramSchema>;
export type TvSeasonContentProgram = z.infer<
typeof TvSeasonContentProgramSchema
>;
export type MusicArtistContentProgram = z.infer<
typeof MusicArtistContentProgramSchema
>;
export type MusicAlbumContentProgram = z.infer<
typeof MusicAlbumContentProgramSchema
>;
export type FlexProgram = z.infer<typeof FlexProgramSchema>;
export type CustomProgram = z.infer<typeof CustomProgramSchema>;

View File

@@ -20,7 +20,9 @@ import {
ContentProgramSchema,
CustomProgramSchema,
FlexProgramSchema,
MusicAlbumContentProgramSchema,
RedirectProgramSchema,
TvSeasonContentProgramSchema,
} from '../schemas/programmingSchema.js';
import {
BackupSettingsSchema,
@@ -40,6 +42,13 @@ export const IdPathParamSchema = z.object({
id: z.string(),
});
export function PagedResult<T extends z.ZodType>(schema: T) {
return z.object({
total: z.number(),
result: schema,
});
}
export const ChannelNumberParamSchema = z.object({
number: z.coerce.number(),
});
@@ -354,3 +363,19 @@ export const TimeSlotScheduleResult = z.object({
});
export type TimeSlotScheduleResult = z.infer<typeof TimeSlotScheduleResult>;
export const ProgramChildrenResult = PagedResult(
z
.object({
type: z.enum(['season', 'album']),
programs: z
.array(TvSeasonContentProgramSchema)
.or(z.array(MusicAlbumContentProgramSchema)),
})
.or(
z.object({
type: z.enum(['episode', 'track']),
programs: z.array(ContentProgramSchema),
}),
),
);

View File

@@ -121,7 +121,7 @@ export const ContentProgramTypeSchema = z.enum([
export type ContentProgramType = z.infer<typeof ContentProgramTypeSchema>;
export const ContentProgramParentSchema = z.object({
const BaseContentProgramParentSchema = z.object({
// ID of the program_grouping in Tunarr
id: z.string().optional(),
// title - e.g. album, show, etc
@@ -134,8 +134,38 @@ export const ContentProgramParentSchema = z.object({
year: z.number().nonnegative().optional().catch(undefined),
externalKey: z.string().optional(),
externalIds: z.array(ExternalIdSchema),
summary: z.string().optional(),
});
export const TvSeasonContentProgramSchema = z.object({
...BaseContentProgramParentSchema.shape,
type: z.literal('season'),
});
export const TvShowContentProgramSchema = z.object({
...BaseContentProgramParentSchema.shape,
type: z.literal('show'),
seasons: z.array(BaseContentProgramParentSchema).optional(),
});
export const MusicAlbumContentProgramSchema = z.object({
...BaseContentProgramParentSchema.shape,
type: z.literal('album'),
});
export const MusicArtistContentProgramSchema = z.object({
...BaseContentProgramParentSchema.shape,
type: z.literal('artist'),
albums: z.array(BaseContentProgramParentSchema).optional(),
});
export const ContentProgramParentSchema = z.discriminatedUnion('type', [
TvSeasonContentProgramSchema,
TvShowContentProgramSchema,
MusicAlbumContentProgramSchema,
MusicArtistContentProgramSchema,
]);
// Unfortunately we can't make this a discrim union, or even a regular union,
// because it is used in other discriminatedUnions and zod cannot handle this
// See:
@@ -171,8 +201,12 @@ export const ContentProgramSchema = CondensedContentProgramSchema.extend({
// Index of this item relative to its parent
index: z.number().nonnegative().optional().catch(undefined),
// ID of the program_grouping in Tunarr
parent: ContentProgramParentSchema.optional(),
grandparent: ContentProgramParentSchema.optional(),
parent: TvSeasonContentProgramSchema.or(
MusicAlbumContentProgramSchema,
).optional(),
grandparent: TvShowContentProgramSchema.or(
MusicArtistContentProgramSchema,
).optional(),
// External source metadata
externalSourceType: ExternalSourceTypeSchema,
externalSourceName: z.string(),

View File

@@ -52,6 +52,7 @@
"react-hook-form": "^7.48.2",
"react-markdown": "^9.0.3",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.9",
"ts-pattern": "^5.4.0",
"usehooks-ts": "^2.14.0",

View File

@@ -205,7 +205,11 @@ export const Drawer = ({ onOpen, onClose }: Props) => {
{navItems
.filter((item) => !item.hidden)
.map((item) => (
<DrawerItem item={item} drawerState={drawerState} />
<DrawerItem
key={item.name}
item={item}
drawerState={drawerState}
/>
))}
<Divider sx={{ my: 1 }} />
</List>

View File

@@ -1,4 +1,5 @@
import { Box, Collapse, lighten, type Theme } from '@mui/material';
import type { ReactNode } from 'react';
import { useCallback, useRef } from 'react';
import { useBoolean } from 'usehooks-ts';
import { useIsDarkMode } from '../hooks/useTunarrTheme.ts';
@@ -10,7 +11,7 @@ interface InlineModalProps<ItemType> {
modalItem: Nullable<ItemType>;
depth: number;
extractItemId: (item: ItemType) => string;
getChildGrid: (props: NestedGridProps<ItemType>) => JSX.Element; //ComponentType<MediaItemGridProps<PageDataType, ItemType>>
getChildGrid: (props: NestedGridProps<ItemType>) => ReactNode;
}
export function InlineModal<ItemType>(props: InlineModalProps<ItemType>) {

View File

@@ -360,7 +360,7 @@ type GuideTime = {
stop?: Dayjs;
};
export default function ChannelProgrammingList(props: Props) {
export default function ChannelLineupList(props: Props) {
const {
deleteProgram = defaultProps.deleteProgram,
moveProgram = defaultProps.moveProgram,

View File

@@ -45,7 +45,7 @@ import { ProgramCalendarView } from '../slot_scheduler/ProgramCalendarView.tsx';
import { ProgramDayCalendarView } from '../slot_scheduler/ProgramDayCalendarView.tsx';
import { ProgramWeekCalendarView } from '../slot_scheduler/ProgramWeekCalendarView.tsx';
import AddProgrammingButton from './AddProgrammingButton.tsx';
import ChannelProgrammingList from './ChannelProgrammingList.tsx';
import ChannelLineupList from './ChannelLineupList.tsx';
import { ChannelProgrammingSort } from './ChannelProgrammingSort.tsx';
import { ChannelProgrammingTools } from './ChannelProgrammingTools.tsx';
@@ -175,7 +175,7 @@ export function ChannelProgrammingConfig() {
switch (view) {
case 'list':
return (
<ChannelProgrammingList
<ChannelLineupList
type="selector"
virtualListProps={{
width: '100%',

View File

@@ -25,6 +25,7 @@ import { useShallow } from 'zustand/react/shallow';
import { useIsDarkMode } from '../../hooks/useTunarrTheme.ts';
import useStore from '../../store/index.ts';
import type {
EmbySelectedMedia,
JellyfinSelectedMedia,
PlexSelectedMedia,
SelectedMedia,
@@ -39,7 +40,7 @@ export type GridItemMetadata = {
title: string;
subtitle: JSX.Element | string | null;
thumbnailUrl: string;
selectedMedia: SelectedMedia;
selectedMedia?: SelectedMedia;
isFolder?: boolean;
};
@@ -53,6 +54,8 @@ type Props<T> = {
onClick: (item: T) => void;
onSelect: (item: T) => void;
depth: number;
enableSelection?: boolean;
disablePadding?: boolean;
};
const MediaGridItemInner = <T,>(
@@ -84,6 +87,8 @@ const MediaGridItemInner = <T,>(
isModalOpen,
onClick,
depth,
enableSelection = true,
disablePadding = false,
} = props;
const [imageLoaded, setImageLoaded] = useState<
@@ -94,7 +99,9 @@ const MediaGridItemInner = <T,>(
useShallow((s) =>
filter(
s.selectedMedia,
(p): p is PlexSelectedMedia | JellyfinSelectedMedia =>
(
p,
): p is PlexSelectedMedia | JellyfinSelectedMedia | EmbySelectedMedia =>
p.type !== 'custom-show',
),
),
@@ -116,13 +123,15 @@ const MediaGridItemInner = <T,>(
const handleItem = useCallback(
(e: MouseEvent<HTMLDivElement | HTMLButtonElement>) => {
e.stopPropagation();
if (isSelected) {
removeSelectedMedia([selectedMediaItem]);
} else {
addSelectedMedia(selectedMediaItem);
if (enableSelection && selectedMediaItem) {
if (isSelected) {
removeSelectedMedia([selectedMediaItem]);
} else {
addSelectedMedia(selectedMediaItem);
}
}
},
[isSelected, selectedMediaItem],
[enableSelection, isSelected, selectedMediaItem],
);
const { isIntersecting: isInViewport, ref: imageContainerRef } =
@@ -180,9 +189,9 @@ const MediaGridItemInner = <T,>(
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
paddingLeft: '8px !important',
paddingRight: '8px',
paddingTop: '8px',
paddingLeft: disablePadding ? undefined : '8px !important',
paddingRight: disablePadding ? undefined : '8px',
paddingTop: disablePadding ? undefined : '8px',
height: 'auto',
backgroundColor: backgroundColor,
...style,
@@ -270,14 +279,16 @@ const MediaGridItemInner = <T,>(
subtitle={subtitle}
position="below"
actionIcon={
<IconButton
aria-label={`star ${title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
handleItem(event)
}
>
{isSelected ? <CheckCircle /> : <RadioButtonUnchecked />}
</IconButton>
enableSelection ? (
<IconButton
aria-label={`star ${title}`}
onClick={(event: MouseEvent<HTMLButtonElement>) =>
handleItem(event)
}
>
{isSelected ? <CheckCircle /> : <RadioButtonUnchecked />}
</IconButton>
) : null
}
actionPosition="right"
/>

View File

@@ -24,7 +24,7 @@ import {
map,
sumBy,
} from 'lodash-es';
import type { ComponentType, ForwardedRef } from 'react';
import type { ComponentType, ForwardedRef, ReactNode } from 'react';
import {
Fragment,
useCallback,
@@ -66,7 +66,7 @@ export interface NestedGridProps<ItemType> {
export type RenderNestedGrid<ItemType> = (
props: NestedGridProps<ItemType>,
) => JSX.Element;
) => ReactNode;
export interface GridInlineModalProps<ItemType> {
open: boolean;
@@ -86,7 +86,7 @@ export type MediaItemGridProps<PageDataType, ItemType> = {
// query, changing the items displayed.
handleAlphaNumFilter?: (key: string | null) => void;
renderNestedGrid: RenderNestedGrid<ItemType>;
renderGridItem: (props: GridItemProps<ItemType>) => JSX.Element;
renderGridItem: (props: GridItemProps<ItemType>) => ReactNode;
depth?: number;
};
@@ -412,6 +412,7 @@ export function MediaItemGrid<PageDataType, ItemType>(
height: gridItemRef?.getBoundingClientRect()?.height ?? 200,
}}
ref={ref}
className="loading-more-target"
></div>
)}
{isFetchingNextPage && (

View File

@@ -0,0 +1,238 @@
import { ZoomIn } from '@mui/icons-material';
import {
Box,
Button,
Card,
CardActions,
CardContent,
CardMedia,
Fade,
Link,
Skeleton,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { createExternalId } from '@tunarr/shared';
import * as globalDayjs from 'dayjs';
import { capitalize, isUndefined } from 'lodash-es';
import { useMemo, useState } from 'react';
import { match, P } from 'ts-pattern';
import { useTimeout, useToggle } from 'usehooks-ts';
import { useChannelAndProgramming } from '../../hooks/useChannelLineup.ts';
import { useDayjs } from '../../hooks/useDayjs.ts';
import { useChannelNowPlaying } from '../../hooks/useTvGuide.ts';
import { useSettings } from '../../store/settings/selectors.ts';
import ProgramDetailsDialog from '../ProgramDetailsDialog.tsx';
import { NetworkIcon } from '../util/NetworkIcon.tsx';
type Props = {
channelId: string;
};
type ProgramDetails = {
title: string;
showTitle?: string;
seasonAndEpisode?: {
episode: number;
season: number;
};
};
export const ChannelNowPlayingCard = ({ channelId }: Props) => {
const dayjs = useDayjs();
const { backendUri } = useSettings();
const {
data: { lineup },
} = useChannelAndProgramming(channelId);
const { data: firstProgram } = useChannelNowPlaying(channelId);
const [open, toggleOpen] = useToggle(false);
const theme = useTheme();
const smallViewport = useMediaQuery(theme.breakpoints.down('sm'));
const queryClient = useQueryClient();
useTimeout(() => {
queryClient
.invalidateQueries({
queryKey: ['channels', channelId, 'now_playing'],
})
.catch(console.error);
}, firstProgram.stop + 5_000);
const details = useMemo(() => {
return match(firstProgram)
.returnType<ProgramDetails | null>()
.with(P.nullish, () => null)
.with({ type: 'content', subtype: P.union('episode', 'track') }, (c) => ({
title: c.title,
showTitle: lineup.programs[c.id]?.grandparent?.title,
seasonAndEpisode:
!isUndefined(lineup.programs[c.id]?.index) &&
!isUndefined(lineup.programs[c.id]?.parent?.index)
? {
season: lineup.programs[c.id].parent!.index!,
episode: lineup.programs[c.id].index!,
}
: undefined,
}))
.with({ type: 'content' }, (c) => ({ title: c.title }))
.with({ type: 'custom' }, (c) => ({
title: c.program?.title ?? `Custom`,
}))
.with({ type: 'flex' }, () => ({ title: 'Flex' }))
.with({ type: 'redirect' }, (c) => ({
title: `Redirect to ${c.channelNumber}`,
}))
.exhaustive();
}, [firstProgram, lineup.programs]);
const imageUrl = useMemo(() => {
if (!firstProgram) {
return;
}
if (firstProgram.type !== 'content') {
return; // Handle others
}
const program = lineup.programs[firstProgram.id];
if (!program) {
return;
}
const id =
program.subtype === 'movie' ||
program.subtype === 'music_video' ||
program.subtype === 'other_video'
? program.externalKey
: program.grandparent?.externalKey;
const query = new URLSearchParams({
mode: 'proxy',
asset: 'image',
id: createExternalId(
program.externalSourceType,
program.externalSourceId,
id ?? '',
),
imageType: smallViewport ? 'poster' : 'background',
// Commenting this out for now as temporary solution for image loading issue
// thumbOptions: JSON.stringify({ width: 480, height: 720 }),
cache: import.meta.env.PROD ? 'true' : 'false',
});
return `${backendUri}/api/metadata/external?${query.toString()}`;
}, [backendUri, firstProgram, lineup.programs, smallViewport]);
const [imageLoaded, setImageLoaded] = useState(false);
const handleImageLoad = () => {
setImageLoaded(true);
};
const handleImageError = () => {
console.error(`Failed to load image: ${imageUrl}`);
setImageLoaded(true);
};
return (
<Card
sx={{
display: 'flex',
width: '100%',
position: 'relative',
flexDirection: ['column', 'row'],
}}
>
<Fade in={imageLoaded}>
<CardMedia
component="img"
image={imageUrl}
alt={details?.title}
title={details?.title}
onLoad={handleImageLoad}
onError={handleImageError}
sx={{
display: imageLoaded ? 'block' : 'none',
height: 350,
width: ['100%', '75%'],
objectFit: 'cover',
}}
/>
</Fade>
{!imageLoaded && (
<Skeleton
component="div"
variant="rectangular"
height={350}
animation="wave"
sx={{
minHeight: 350,
width: ['100%', '75%'],
zIndex: 1,
}}
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
}}
>
<CardContent sx={{ flex: 1 }}>
<Typography gutterBottom variant="h4">
Now Playing:
</Typography>
<Typography gutterBottom variant="h5" component="div">
{details?.showTitle ?? details?.title}
</Typography>
{details?.showTitle && <Typography>{details.title}</Typography>}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
Started {dayjs(firstProgram?.start).fromNow()} -{' '}
{globalDayjs
.duration(dayjs(firstProgram?.stop ?? 0).diff(dayjs()))
.humanize() + ' '}
remaining
</Typography>
</CardContent>
<CardActions>
<Button startIcon={<ZoomIn />} size="small" onClick={toggleOpen}>
Details
</Button>
{firstProgram?.type === 'content' && (
<Button
startIcon={
<NetworkIcon
network={firstProgram.externalSourceType}
width={15}
height={15}
/>
}
size="small"
component={Link}
href={`${backendUri}/api/programs/${firstProgram.id}/external-link`}
target="_blank"
>
View in {capitalize(firstProgram.externalSourceType)}
</Button>
)}
</CardActions>
</Box>
{firstProgram.type === 'content' && (
<ProgramDetailsDialog
open={open}
program={lineup.programs[firstProgram.id]}
onClose={toggleOpen}
start={dayjs(firstProgram.start)}
stop={dayjs(firstProgram.stop)}
/>
)}
</Card>
);
};

View File

@@ -0,0 +1,359 @@
import { Box } from '@mui/material';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { ContentProgramParent } from '@tunarr/types';
import { type ContentProgram } from '@tunarr/types';
import type { MultiExternalIdType } from '@tunarr/types/schemas';
import {
isValidMultiExternalIdType,
type ContentProgramType,
} from '@tunarr/types/schemas';
import { identity, isEmpty, last, sumBy } from 'lodash-es';
import { forwardRef, useCallback, useMemo, type ForwardedRef } from 'react';
import { isNonEmptyString, prettyItemDuration } from '../../helpers/util.ts';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
import { useSettings } from '../../store/settings/selectors.ts';
import type { GridItemMetadata } from '../channel_config/MediaGridItem.tsx';
import { MediaGridItem } from '../channel_config/MediaGridItem.tsx';
import type { GridItemProps } from '../channel_config/MediaItemGrid.tsx';
import { MediaItemGrid } from '../channel_config/MediaItemGrid.tsx';
type AllProgramTypes = ContentProgramType | ContentProgramParent['type'];
type Props = {
channelId: string;
programType: AllProgramTypes;
parentId?: string;
depth?: number;
};
const ProgramTypeToChildType: Partial<
Record<AllProgramTypes, AllProgramTypes>
> = {
show: 'season',
season: 'episode',
album: 'track',
artist: 'album',
};
const GridItemImpl = forwardRef(
(
{ item: program, index, depth }: GridItemProps<ContentProgram>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { backendUri } = useSettings();
const metadata = useMemo(() => {
const year =
program.year ??
(program.date ? new Date(program.date).getFullYear() : null);
return {
aspectRatio:
program.subtype === 'track'
? 'square'
: program.subtype === 'music_video' || program.subtype === 'episode'
? 'landscape'
: 'portrait',
childCount: null,
hasThumbnail: true,
isPlaylist: false,
itemId: program.id!,
subtitle: `${prettyItemDuration(program.duration)}${year ? ` (${year})` : ''}`,
thumbnailUrl: `${backendUri}/api/programs/${program.id}/thumb`,
title: program.title,
} satisfies GridItemMetadata;
}, [
backendUri,
program.date,
program.duration,
program.id,
program.subtype,
program.title,
program.year,
]);
return (
<MediaGridItem
ref={ref}
key={program.id}
depth={depth}
index={index}
isModalOpen={false}
item={program}
itemSource={program.externalSourceType}
metadata={metadata}
enableSelection={false}
onClick={() => {}}
onSelect={() => {}}
disablePadding
/>
);
},
);
const ParentGridItemImpl = forwardRef(
(
{
item: program,
index,
moveModal,
depth,
}: GridItemProps<ContentProgramParent>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const { backendUri } = useSettings();
const metadata = useMemo(() => {
const year = program.year;
return {
aspectRatio:
program.type === 'artist' || program.type === 'album'
? 'square'
: 'portrait',
childCount: 1,
hasThumbnail: true,
isPlaylist: false,
itemId: program.id!,
subtitle: year ? (program.year?.toString() ?? null) : null,
thumbnailUrl: `${backendUri}/api/programs/${program.id}/thumb`,
title: program.title ?? '',
} satisfies GridItemMetadata;
}, [backendUri, program.id, program.title, program.type, program.year]);
const externalSourceType = useMemo(
() =>
program.externalIds.find((eid) =>
eid.type === 'multi' &&
isValidMultiExternalIdType(eid.source) &&
isNonEmptyString(eid.sourceId)
? true
: false,
)?.source ?? 'plex',
[program.externalIds],
) as MultiExternalIdType;
const handleClick = useCallback(() => {
moveModal(index, program);
}, [index, moveModal, program]);
return (
<MediaGridItem
ref={ref}
key={program.id}
depth={depth}
index={index}
isModalOpen={false}
item={program}
itemSource={externalSourceType}
metadata={metadata}
enableSelection={false}
onClick={handleClick}
onSelect={() => {}}
disablePadding
/>
);
},
);
function isParentType(type: ContentProgramType | ContentProgramParent['type']) {
switch (type) {
case 'movie':
case 'episode':
case 'track':
case 'music_video':
case 'other_video':
return false;
case 'season':
case 'show':
case 'album':
case 'artist':
return true;
}
}
type GridType = 'terminal' | 'parent' | 'nested';
export const ChannelProgramGrid = ({
channelId,
programType,
depth = 0,
parentId,
}: Props) => {
const apiClient = useTunarrApi();
console.log(parentId, programType);
const hasProgramHierarchy =
programType === 'episode' || programType === 'track';
const gridType = useMemo<GridType>(() => {
if (!hasProgramHierarchy && !isParentType(programType) && !parentId) {
return 'terminal';
} else if (isNonEmptyString(parentId)) {
return 'nested';
} else {
return 'parent';
}
}, [hasProgramHierarchy, parentId, programType]);
const terminalQuery = useInfiniteQuery({
queryKey: ['channels', channelId, 'programs', programType, 'infinite'],
queryFn: ({ pageParam }) =>
apiClient.getChannelPrograms({
params: { id: channelId },
queries: {
type: programType as ContentProgramType,
offset: pageParam,
limit: 50,
},
}),
getNextPageParam: (pages, x) => {
if (pages.length > 0 && isEmpty(last(pages))) {
return null;
}
const totalSize = sumBy(x, (x) => x.length);
return totalSize;
},
initialPageParam: 0,
// These are terminal types
enabled: gridType === 'terminal',
});
const nestedQuery = useInfiniteQuery({
queryKey: ['channels', channelId, 'programs', programType, 'infinite'],
queryFn: ({ pageParam }) =>
programType === 'show'
? apiClient.getChannelShows({
params: { id: channelId },
queries: { offset: pageParam, limit: 50 },
})
: apiClient.getChannelArtists({
params: { id: channelId },
queries: { offset: pageParam, limit: 50 },
}),
getNextPageParam: (currentPage, pages) => {
if (currentPage.result.length === 0) {
return null;
}
const totalSize = sumBy(pages, (page) => page.result.length);
return totalSize;
},
initialPageParam: 0,
// These are terminal types
enabled: gridType === 'parent',
});
const childrenQuery = useInfiniteQuery({
queryKey: [
'channels',
channelId,
'programs',
programType,
'infinite',
parentId,
],
queryFn: ({ pageParam }) =>
apiClient.getProgramChildren({
params: { id: parentId ?? '' },
queries: { offset: pageParam, limit: 50 },
}),
getNextPageParam: (currentPage, allPages) => {
if (currentPage.result.programs.length < 50) {
return null;
}
const totalSize = sumBy(allPages, (page) => page.result.programs.length);
return totalSize;
},
initialPageParam: 0,
// These are terminal types
enabled: gridType === 'nested',
});
const renderContentProgramGridItem = useCallback(
(props: GridItemProps<ContentProgram>) => <GridItemImpl {...props} />,
[],
);
const renderParentProgramGridItem = useCallback(
(props: GridItemProps<ContentProgramParent>) => (
<ParentGridItemImpl {...props} />
),
[],
);
const renderParentOrChild = useCallback(
(props: GridItemProps<ContentProgram | ContentProgramParent>) => {
switch (props.item.type) {
case 'season':
case 'album':
case 'show':
case 'artist':
return renderParentProgramGridItem({ ...props, item: props.item });
case 'content':
return renderContentProgramGridItem({ ...props, item: props.item });
}
},
[renderContentProgramGridItem, renderParentProgramGridItem],
);
return (
<Box
sx={{
height: depth === 0 ? '100vh' : undefined,
mt: depth > 0 ? 1 : 0,
px: depth === 0 ? 0 : 2,
}}
>
{gridType === 'parent' ? (
<MediaItemGrid
infiniteQuery={nestedQuery}
getPageDataSize={(page) => ({
size: page.result.length,
total: page.total,
})}
extractItems={(page) => page.result}
renderNestedGrid={(props) => (
<ChannelProgramGrid
channelId={channelId}
depth={props.depth}
programType={ProgramTypeToChildType[programType]!}
parentId={props.parent?.id}
/>
)}
renderGridItem={renderParentProgramGridItem}
getItemKey={(p) => p.id!}
depth={depth}
/>
) : gridType === 'nested' ? (
<MediaItemGrid
infiniteQuery={childrenQuery}
getPageDataSize={(page) => ({
size: page.result.programs.length,
total: page.total,
})}
extractItems={(page) => page.result.programs}
renderNestedGrid={(props) => (
<ChannelProgramGrid
channelId={channelId}
depth={props.depth}
programType={ProgramTypeToChildType[programType]!}
parentId={props.parent?.id}
/>
)}
renderGridItem={renderParentOrChild}
getItemKey={(p) => p.id!}
depth={depth}
/>
) : (
<MediaItemGrid
infiniteQuery={terminalQuery}
getPageDataSize={(page) => ({ size: page.length })}
extractItems={identity}
renderNestedGrid={() => null}
renderGridItem={renderContentProgramGridItem}
getItemKey={(p) => p.id!}
depth={depth}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,202 @@
import type { TabProps } from '@mui/material';
import { Tab, Tabs, Typography } from '@mui/material';
import { seq } from '@tunarr/shared/util';
import type { ContentProgram, ContentProgramParent } from '@tunarr/types';
import {
ContentProgramTypeSchema,
type ContentProgramType,
} from '@tunarr/types/schemas';
import { groupBy, isNil, keys, mapValues, omitBy } from 'lodash-es';
import { useMemo, useState } from 'react';
import { useChannelAndProgramming } from '../../hooks/useChannelLineup.ts';
import { TabPanel } from '../TabPanel.tsx';
import { ChannelProgramGrid } from './ChannelProgramGrid.tsx';
type Props = {
channelId: string;
};
type ProgramTabProps = TabProps & {
selected: boolean;
programCount: number;
programType: ContentProgramType;
};
const ProgramTypeToLabel: Record<ContentProgramType, string> = {
episode: 'Shows',
movie: 'Movies',
music_video: 'Music Videos',
other_video: 'Other Videos',
track: 'Artists',
};
const ProgramTypeToGridType: Record<
ContentProgramType,
ContentProgramType | ContentProgramParent['type']
> = {
episode: 'show',
movie: 'movie',
music_video: 'music_video',
other_video: 'other_video',
track: 'artist',
};
const ProgramTypeTab = ({
programCount,
programType,
selected,
...rest
}: ProgramTabProps) => {
return (
<Tab
{...rest}
label={
<>
<Typography
component="span"
sx={{ verticalAlign: 'middle', fontSize: '0.875rem' }}
>
{ProgramTypeToLabel[programType]}
<Typography
component="span"
sx={{
display: 'inline-block',
ml: 1,
height: 21,
minWidth: 21,
backgroundColor: (theme) =>
selected
? theme.palette.primary.main
: theme.palette.mode === 'dark'
? theme.palette.grey[800]
: theme.palette.grey[400],
color: (theme) =>
selected
? theme.palette.getContrastText(theme.palette.primary.main)
: 'inherit',
borderRadius: 10,
px: 0.8,
fontSize: 'inherit',
}}
>
{programCount}
</Typography>
</Typography>
</>
}
disabled={programCount === 0}
/>
);
};
export const ChannelPrograms = ({ channelId }: Props) => {
const {
data: {
lineup: { lineup, programs },
},
} = useChannelAndProgramming(channelId);
const programsByType = useMemo(
() =>
groupBy(
seq.collect(lineup, (p) => {
if (p.type === 'content' && p.id) {
return programs[p.id];
} else if (p.type === 'custom') {
return programs[p.id];
}
return;
}),
(p) => p.subtype,
),
[lineup, programs],
) as Record<ContentProgramType, ContentProgram[]>;
// TODO: Do this in the database
const [epsByShow] = useMemo(() => {
const epsByProgram = mapValues(
omitBy(
groupBy(programsByType['episode'], (ep) => ep.grandparent?.id),
isNil,
),
(p) => p.length,
);
const epsBySeason = mapValues(
omitBy(
groupBy(programsByType['episode'], (ep) => ep.parent?.id),
isNil,
),
(p) => p.length,
);
return [epsByProgram, epsBySeason];
}, [programsByType]);
const [tracksByArtist] = useMemo(() => {
const epsByProgram = mapValues(
omitBy(
groupBy(programsByType['track'], (ep) => ep.grandparent?.id),
isNil,
),
(p) => p.length,
);
const epsBySeason = mapValues(
omitBy(
groupBy(programsByType['track'], (ep) => ep.parent?.id),
isNil,
),
(p) => p.length,
);
return [epsByProgram, epsBySeason];
}, [programsByType]);
const [tab, setTab] = useState(() => {
for (const [key, programs] of Object.entries(programsByType)) {
if (programs.length > 0) {
switch (key as ContentProgramType) {
case 'movie':
return 0;
case 'episode':
return 1;
case 'track':
return 2;
case 'music_video':
return 3;
case 'other_video':
return 4;
}
}
}
return 0;
});
return (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v as number)}>
{Object.values(ContentProgramTypeSchema.enum).map((v, idx) => (
<ProgramTypeTab
key={v}
value={idx}
programCount={
v === 'episode'
? keys(epsByShow).length
: v === 'track'
? keys(tracksByArtist).length
: (programsByType[v]?.length ?? 0)
}
programType={v}
selected={tab === idx}
/>
))}
</Tabs>
{Object.values(ContentProgramTypeSchema.enum).map((v, idx) => (
<TabPanel index={idx} value={tab} key={v}>
<ChannelProgramGrid
channelId={channelId}
programType={ProgramTypeToGridType[v]}
/>
</TabPanel>
))}
</>
);
};

View File

@@ -0,0 +1,117 @@
import { Box, Grid, Paper, Stack, Typography } from '@mui/material';
import { seq } from '@tunarr/shared/util';
import type { ChannelStreamMode } from '@tunarr/types';
import * as globalDayjs from 'dayjs';
import { find, round, uniq } from 'lodash-es';
import { useMemo } from 'react';
import { match } from 'ts-pattern';
import { pluralizeWithCount } from '../../helpers/util.ts';
import { useTranscodeConfigs } from '../../hooks/settingsHooks.ts';
import { useChannelAndProgramming } from '../../hooks/useChannelLineup.ts';
const ChannelStreamModeToPrettyString: Record<ChannelStreamMode, string> = {
hls: 'HLS',
hls_direct: 'HLS Direct',
hls_slower: 'HLS (alt)',
mpegts: 'MPEG-TS',
};
type Props = {
channelId: string;
};
export const ChannelSummaryQuickStats = ({ channelId }: Props) => {
const {
data: { channel, lineup },
} = useChannelAndProgramming(channelId);
const { data: transcodeConfigs } = useTranscodeConfigs();
const uniqPrograms = useMemo(
() =>
uniq(
seq.collect(lineup.lineup, (p) =>
match(p)
.with({ type: 'content' }, (p) => p.id)
.with({ type: 'custom' }, (p) => p.program?.id)
.otherwise(() => null),
),
).length,
[lineup.lineup],
);
const transcodeConfig = useMemo(
() => find(transcodeConfigs, { id: channel.transcodeConfigId }),
[channel.transcodeConfigId, transcodeConfigs],
);
const channelDurationString = useMemo(() => {
const dur = globalDayjs.duration(channel.duration);
if (+dur <= 0) {
return '0 mins';
}
const days = dur.asDays();
if (days >= 1) {
return pluralizeWithCount('days', round(days, 2));
}
const hours = dur.asHours();
if (hours >= 1) {
return pluralizeWithCount('hour', round(hours));
}
return pluralizeWithCount('minute', round(dur.asMinutes(), 2));
}, [channel.duration]);
return (
<Grid
container
direction={['column', 'row']}
rowSpacing={2}
component={Paper}
sx={{
'> :not(:last-of-type)': {
borderRightColor: [undefined, 'divider'],
borderRightWidth: [undefined, 1],
borderRightStyle: [undefined, 'solid'],
},
}}
>
<Grid size={{ xs: 12, md: 4 }} sx={{ p: 1 }}>
<Stack direction="row">
<div>
<Typography variant="overline">Total Runtime</Typography>
<Typography variant="h5">{channelDurationString}</Typography>
</div>
<Box></Box>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 4 }} sx={{ p: 1 }}>
<Stack direction="row">
<div>
<Typography variant="overline">Programs</Typography>
<Typography variant="h5">{uniqPrograms}</Typography>
</div>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 2 }} sx={{ p: 1 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="overline">Stream Mode</Typography>
<Typography variant="h5">
{ChannelStreamModeToPrettyString[channel.streamMode]}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 2 }} sx={{ p: 1 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="overline">Transcode Config</Typography>
<Typography
sx={{ whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}
variant="h5"
>
{transcodeConfig?.name}
</Typography>
</Box>
</Grid>
</Grid>
);
};

View File

@@ -25,7 +25,7 @@ import { useNavigate } from '@tanstack/react-router';
import { type CustomShow } from '@tunarr/types';
import { useEffect } from 'react';
import { Controller, type SubmitHandler, useForm } from 'react-hook-form';
import ChannelProgrammingList from '../channel_config/ChannelProgrammingList';
import ChannelLineupList from '../channel_config/ChannelLineupList.tsx';
import { CustomShowSortToolsMenu } from './CustomShowSortToolsMenu.tsx';
type CustomShowForm = {
@@ -169,7 +169,7 @@ export function EditCustomShowsForm({
</Stack>
</Box>
<Paper>
<ChannelProgrammingList
<ChannelLineupList
type="selector"
programListSelector={(s) => s.customShowEditor.programList}
moveProgram={moveProgramInCustomShow}

View File

@@ -15,7 +15,7 @@ import type { SubmitHandler } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { useTunarrApi } from '../../hooks/useTunarrApi.ts';
import type { UIFillerListProgram } from '../../types/index.ts';
import ChannelProgrammingList from '../channel_config/ChannelProgrammingList.tsx';
import ChannelLineupList from '../channel_config/ChannelLineupList.tsx';
export type FillerListMutationArgs = {
id?: string;
@@ -148,7 +148,7 @@ export function EditFillerListForm({
</Button>
</Tooltip>
</Stack>
<ChannelProgrammingList
<ChannelLineupList
type="selector"
programListSelector={(s) => s.fillerListEditor.programList}
enableDnd={false}

View File

@@ -0,0 +1,19 @@
import EmbyLogo from '@/assets/emby.svg?react';
import JellyfinLogo from '@/assets/jellyfin.svg?react';
import PlexLogo from '@/assets/plex.svg?react';
import type { SvgIconProps } from '@mui/material';
import type { MediaSourceType } from '@tunarr/types';
import { match } from 'ts-pattern';
type Props = SvgIconProps & {
network: MediaSourceType;
};
export const NetworkIcon = ({ network, ...rest }: Props) => {
const Icon = match(network)
.with('plex', () => PlexLogo)
.with('emby', () => EmbyLogo)
.with('jellyfin', () => JellyfinLogo)
.exhaustive();
return <Icon {...rest} />;
};

View File

@@ -4,6 +4,8 @@ import {
ChannelSessionsResponseSchema,
CreateCustomShowRequestSchema,
CreateFillerListRequestSchema,
PagedResult,
ProgramChildrenResult,
TimeSlotScheduleResult,
TimeSlotScheduleSchema,
UpdateChannelProgrammingRequestSchema,
@@ -15,7 +17,9 @@ import {
ChannelLineupSchema,
ChannelSchema,
CondensedChannelProgrammingSchema,
ContentProgramParentSchema,
ContentProgramSchema,
ContentProgramTypeSchema,
CreateChannelRequestSchema,
CustomProgramSchema,
CustomShowSchema,
@@ -25,6 +29,7 @@ import {
SaveableChannelSchema,
TaskSchema,
TranscodeConfigSchema,
TvGuideProgramSchema,
} from '@tunarr/types/schemas';
import {
Zodios,
@@ -129,9 +134,62 @@ export const api = makeApi([
method: 'get',
path: '/api/channels/:id/lineup',
response: ChannelLineupSchema,
parameters: parametersBuilder().addPath('id', z.string()).build(),
parameters: parametersBuilder()
.addPath('id', z.string())
.addQueries({
from: z.iso.date(),
to: z.iso.date(),
})
.build(),
alias: 'getChannelLineup',
},
{
method: 'get',
path: '/api/channels/:id/now_playing',
response: TvGuideProgramSchema,
parameters: parametersBuilder().addPath('id', z.string()).build(),
alias: 'getChannelNowPlaying',
},
{
method: 'get',
path: '/api/channels/:id/shows',
response: PagedResult(z.array(ContentProgramParentSchema)),
parameters: parametersBuilder()
.addPath('id', z.string())
.addQueries({
offset: z.number().nonnegative().default(0),
limit: z.number().min(-1).default(-1),
})
.build(),
alias: 'getChannelShows',
},
{
method: 'get',
path: '/api/channels/:id/artists',
response: PagedResult(z.array(ContentProgramParentSchema)),
parameters: parametersBuilder()
.addPath('id', z.string())
.addQueries({
offset: z.number().nonnegative().default(0),
limit: z.number().min(-1).default(-1),
})
.build(),
alias: 'getChannelArtists',
},
{
method: 'get',
path: '/api/channels/:id/programs',
response: z.array(ContentProgramSchema),
parameters: parametersBuilder()
.addPath('id', z.string())
.addQueries({
offset: z.number().nonnegative().default(0),
limit: z.number().min(-1).default(-1),
type: ContentProgramTypeSchema.optional(),
})
.build(),
alias: 'getChannelPrograms',
},
{
method: 'get',
path: '/api/channels/:id/sessions',
@@ -449,6 +507,19 @@ export const api = makeApi([
.build(),
response: TimeSlotScheduleResult,
},
{
method: 'get',
path: '/api/programs/:id/children',
alias: 'getProgramChildren',
parameters: parametersBuilder()
.addPath('id', z.string())
.addQueries({
offset: z.number().nonnegative().default(0),
limit: z.number().min(-1).default(-1),
})
.build(),
response: ProgramChildrenResult,
},
...settingsEndpoints,
...jellyfinEndpoints,
...embyEndpoints,

View File

@@ -40,7 +40,6 @@ const namedRoutes: Route[] = [
store.channelEditor.currentEntity?.id === maybeId
? store.channelEditor.currentEntity.name
: undefined,
isLink: false,
// name: (store) => store.channelEditor.,
},
{

View File

@@ -1,34 +1,64 @@
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ChannelLineup } from '@tunarr/types';
import type {
DefaultError,
UseQueryOptions,
UseQueryResult,
UseSuspenseQueryOptions,
} from '@tanstack/react-query';
import {
queryOptions,
useQuery,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query';
import type { ChannelLineup, TvGuideProgram } from '@tunarr/types';
import type { Dayjs } from 'dayjs';
import { identity, isUndefined } from 'lodash-es';
import { useEffect } from 'react';
import type { StrictOmit } from 'ts-essentials';
import type { ApiClient } from '../external/api.ts';
import { useTunarrApi } from './useTunarrApi.ts';
const dateRangeQueryKey = (range: { from: Dayjs; to: Dayjs }) =>
`${range.from.unix()}_${range.to.unix()}`;
`${+range.from}_${+range.to}`;
function lineupQueryOpts<Out = ChannelLineup | undefined>(
type ChannelLineupQueryKey = ['channels', string, 'guide', string];
type ChannelLineupQueryOpts<Out = ChannelLineup> = UseQueryOptions<
ChannelLineup,
DefaultError,
Out,
ChannelLineupQueryKey
>;
type AllLineupsQueryOpts = UseQueryOptions<
ChannelLineup[],
DefaultError,
ChannelLineup[],
ChannelLineupQueryKey
>;
function lineupQueryOpts<Out = ChannelLineup>(
apiClient: ApiClient,
channelId: string,
range: { from: Dayjs; to: Dayjs },
mapper: (lineup: ChannelLineup | undefined) => Out = identity,
) {
return {
queryKey: ['channels', channelId, 'guide', dateRangeQueryKey(range)],
queryFn: async () => {
return apiClient
.get('/api/channels/:id/lineup', {
params: { id: channelId },
queries: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
})
.then(mapper);
mapper: (lineup: ChannelLineup) => Out = identity,
): ChannelLineupQueryOpts<Out> {
return queryOptions({
queryKey: [
'channels',
channelId,
'guide',
dateRangeQueryKey(range),
] satisfies ChannelLineupQueryKey,
queryFn: () => {
return apiClient.getChannelLineup({
params: { id: channelId },
queries: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
});
},
};
select: mapper,
});
}
const allLineupsQueryOpts = (
@@ -37,23 +67,31 @@ const allLineupsQueryOpts = (
from: Dayjs;
to: Dayjs;
},
): UseQueryOptions<ChannelLineup[]> => ({
queryKey: ['channels', 'all', 'guide', dateRangeQueryKey(range)],
queryFn: async () => {
return apiClient.get('/api/channels/all/lineups', {
queries: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
});
},
});
): AllLineupsQueryOpts =>
queryOptions({
queryKey: [
'channels',
'all',
'guide',
dateRangeQueryKey(range),
] satisfies ChannelLineupQueryKey,
queryFn: () => {
return apiClient.getAllChannelLineups({
queries: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
});
},
});
export const useTvGuide = (params: {
type UseTvGuideOpts = {
channelId: string;
from: Dayjs;
to: Dayjs;
}) => {
};
export const useTvGuide = (params: UseTvGuideOpts) => {
const client = useTunarrApi();
return useQuery(
lineupQueryOpts(client, params.channelId, {
@@ -63,14 +101,59 @@ export const useTvGuide = (params: {
);
};
export const useChannelNowPlaying = (
channelId: string,
opts: Partial<
StrictOmit<
UseSuspenseQueryOptions<TvGuideProgram, DefaultError>,
'queryKey' | 'queryFn'
>
> = {},
) => {
const client = useTunarrApi();
// const fullOpts: UseSuspenseQueryOptions<
// ChannelLineup,
// DefaultError,
// Out,
// ChannelLineupQueryKey
// > = useMemo(
// () => ({
// ...lineupQueryOpts(client, params.channelId, {
// from: params.from,
// to: params.to,
// }),
// ...opts,
// }),
// [client, opts, params.channelId, params.from, params.to],
// );
return useSuspenseQuery({
queryKey: ['channels', channelId, 'now_playing'],
queryFn: () => client.getChannelNowPlaying({ params: { id: channelId } }),
...opts,
});
};
export const useTvGuides = (
channelId: string,
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
): UseQueryResult<ChannelLineup[], Error> => {
extraOpts: Partial<
Omit<
UseQueryOptions<
any,
DefaultError,
ChannelLineup[],
ChannelLineupQueryKey
>,
'queryKey' | 'queryFn' | 'enabled'
>
> = {},
): UseQueryResult<ChannelLineup[]> => {
const client = useTunarrApi();
const singleChannelResult = useQuery({
...lineupQueryOpts(client, channelId, params, (lineup) =>
...lineupQueryOpts<ChannelLineup[]>(client, channelId, params, (lineup) =>
!isUndefined(lineup) ? [lineup] : [],
),
...extraOpts,
@@ -88,29 +171,34 @@ export const useTvGuides = (
export const useTvGuidesPrefetch = (
channelId: string,
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
extraOpts: Partial<Omit<AllLineupsQueryOpts, 'queryKey' | 'queryFn'>> = {},
) => {
const client = useTunarrApi();
const queryClient = useQueryClient();
const query: UseQueryOptions<ChannelLineup[]> =
channelId !== 'all'
? {
useEffect(() => {
if (channelId === 'all') {
queryClient
.prefetchQuery({
...allLineupsQueryOpts(client, params),
...extraOpts,
})
.catch(console.error);
} else {
queryClient
.prefetchQuery({
...lineupQueryOpts(client, channelId, params, (lineup) =>
!isUndefined(lineup) ? [lineup] : [],
),
...extraOpts,
}
: {
...allLineupsQueryOpts(client, params),
...extraOpts,
};
queryClient.prefetchQuery(query).catch(console.error);
})
.catch(console.error);
}
}, [channelId, client, extraOpts, params, queryClient]);
};
export const useAllTvGuides = (
params: { from: Dayjs; to: Dayjs },
extraOpts: Partial<UseQueryOptions<ChannelLineup[]>> = {},
extraOpts: Partial<Omit<AllLineupsQueryOpts, 'queryKey' | 'queryFn'>> = {},
) => {
const client = useTunarrApi();
return useQuery({ ...allLineupsQueryOpts(client, params), ...extraOpts });

View File

@@ -0,0 +1,64 @@
import { AddToQueue, Settings } from '@mui/icons-material';
import {
Box,
IconButton,
LinearProgress,
Stack,
Tooltip,
Typography,
} from '@mui/material';
import { Link } from '@tanstack/react-router';
import { Suspense } from 'react';
import Breadcrumbs from '../../components/Breadcrumbs.tsx';
import { ChannelNowPlayingCard } from '../../components/channels/ChannelNowPlayingCard.tsx';
import { ChannelPrograms } from '../../components/channels/ChannelPrograms.tsx';
import { ChannelSummaryQuickStats } from '../../components/channels/ChannelSummaryQuickStats.tsx';
import TunarrLogo from '../../components/TunarrLogo.tsx';
import { isNonEmptyString } from '../../helpers/util.ts';
import { useChannelAndProgramming } from '../../hooks/useChannelLineup.ts';
import { Route } from '../../routes/channels/$channelId.tsx';
export const ChannelSummaryPage = () => {
const { channelId } = Route.useParams();
const {
data: { channel },
} = useChannelAndProgramming(channelId);
return (
<Stack spacing={2}>
<Breadcrumbs />
<Stack direction="row" alignItems="center" spacing={1}>
<Box>
{isNonEmptyString(channel.icon.path) ? (
<Box component="img" src={channel.icon.path} />
) : (
<TunarrLogo style={{ width: '132px' }} />
)}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h3">{channel.name}</Typography>
<Typography variant="subtitle1">Channel #{channel.number}</Typography>
</Box>
<Tooltip title="Edit" placement="top">
<IconButton component={Link} from={Route.fullPath} to="./edit">
<Settings />
</IconButton>
</Tooltip>
<Tooltip title="Add Programming" placement="top">
<IconButton component={Link} from={Route.fullPath} to="./programming">
<AddToQueue />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ width: '100%' }}>
<Suspense fallback={<LinearProgress />}>
<ChannelNowPlayingCard channelId={channelId} />
</Suspense>
</Box>
<ChannelSummaryQuickStats channelId={channelId} />
<ChannelPrograms channelId={channelId} />
</Stack>
);
};

View File

@@ -163,7 +163,7 @@ export default function ChannelsPage() {
id: string,
) => {
navigate({
to: `/channels/$channelId/programming`,
to: `/channels/$channelId`,
params: { channelId: id },
}).catch(console.error);
};

View File

@@ -37,7 +37,7 @@ import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
import type { MarkRequired, StrictOmit } from 'ts-essentials';
import Breadcrumbs from '../../components/Breadcrumbs';
import PaddedPaper from '../../components/base/PaddedPaper';
import ChannelProgrammingList from '../../components/channel_config/ChannelProgrammingList';
import ChannelLineupList from '../../components/channel_config/ChannelLineupList.tsx';
import UnsavedNavigationAlert from '../../components/settings/UnsavedNavigationAlert';
import { defaultRandomSlotSchedule } from '../../helpers/constants.ts';
import { getProgramGroupingKey } from '../../helpers/programUtil.ts';
@@ -216,7 +216,7 @@ export default function RandomSlotEditorPage() {
</Stack>
<Divider />
<Box sx={{ minHeight: 400 }}>
<ChannelProgrammingList
<ChannelLineupList
type="selector"
enableDnd={false}
enableRowDelete={false}

View File

@@ -1,5 +1,5 @@
import { RotatingLoopIcon } from '@/components/base/LoadingIcon.tsx';
import ChannelProgrammingList from '@/components/channel_config/ChannelProgrammingList.tsx';
import ChannelLineupList from '@/components/channel_config/ChannelLineupList.tsx';
import { MissingProgramsAlert } from '@/components/slot_scheduler/MissingProgramsAlert.tsx';
import { TimeSlotFormProvider } from '@/components/slot_scheduler/TimeSlotFormProvider.tsx';
import { TimeSlotTable } from '@/components/slot_scheduler/TimeSlotTable.tsx';
@@ -471,7 +471,7 @@ export default function TimeSlotEditorPage() {
disabled
slotProps={{ textField: { size: 'small' } }}
/>
<ChannelProgrammingList
<ChannelLineupList
type="selector"
enableDnd={false}
enableRowDelete={false}

View File

@@ -1,13 +1,18 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
import { createFileRoute } from '@tanstack/react-router';
import { ChannelSummaryPage } from '../../pages/channels/ChannelSummaryPage.tsx';
export const Route = createFileRoute('/channels/$channelId')({
loader: (ctx) => {
const channelId = ctx.params.channelId;
throw redirect({
to: '/channels/$channelId/programming',
params: {
channelId,
},
});
},
loader: preloadChannelAndProgramming,
component: ChannelSummaryPage,
// loader: (ctx) => {
// const channelId = ctx.params.channelId;
// throw redirect({
// to: '/channels/$channelId/programming',
// params: {
// channelId,
// },
// });
// },
});