mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat(ui): add channel summary page (#1190)
This commit is contained in:
committed by
GitHub
parent
d9f5e126ec
commit
1f2e5eb7c9
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 }];
|
||||
// },
|
||||
// [],
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
37
server/src/db/schema/derivedTypes.d.ts
vendored
37
server/src/db/schema/derivedTypes.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
24
server/src/external/plex/PlexApiClient.ts
vendored
24
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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,
|
||||
@@ -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%',
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
238
web/src/components/channels/ChannelNowPlayingCard.tsx
Normal file
238
web/src/components/channels/ChannelNowPlayingCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
359
web/src/components/channels/ChannelProgramGrid.tsx
Normal file
359
web/src/components/channels/ChannelProgramGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
202
web/src/components/channels/ChannelPrograms.tsx
Normal file
202
web/src/components/channels/ChannelPrograms.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
117
web/src/components/channels/ChannelSummaryQuickStats.tsx
Normal file
117
web/src/components/channels/ChannelSummaryQuickStats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
19
web/src/components/util/NetworkIcon.tsx
Normal file
19
web/src/components/util/NetworkIcon.tsx
Normal 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} />;
|
||||
};
|
||||
73
web/src/external/api.ts
vendored
73
web/src/external/api.ts
vendored
@@ -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,
|
||||
|
||||
@@ -40,7 +40,6 @@ const namedRoutes: Route[] = [
|
||||
store.channelEditor.currentEntity?.id === maybeId
|
||||
? store.channelEditor.currentEntity.name
|
||||
: undefined,
|
||||
isLink: false,
|
||||
// name: (store) => store.channelEditor.,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
64
web/src/pages/channels/ChannelSummaryPage.tsx
Normal file
64
web/src/pages/channels/ChannelSummaryPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -163,7 +163,7 @@ export default function ChannelsPage() {
|
||||
id: string,
|
||||
) => {
|
||||
navigate({
|
||||
to: `/channels/$channelId/programming`,
|
||||
to: `/channels/$channelId`,
|
||||
params: { channelId: id },
|
||||
}).catch(console.error);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// });
|
||||
|
||||
// },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user