feat: ability to sync custom shows with upstream source

This commit is contained in:
Christian Benincasa
2026-03-27 11:56:35 -04:00
parent 9a6d1b2a0d
commit c07da89b62
36 changed files with 5505 additions and 280 deletions

View File

@@ -3,4 +3,36 @@
!!! info
Custom Shows will be renamed to "Playlists" an in upcoming release
Custom Shows are akin to classic playlists. Any ordering operations in scheduling tools will use the ordering as defined in the Custom Show. Custom Shows can be schedule as if they were "regular" shows in slot editors. Content within are grouped by the Custom Show and not their _actual_ show. This allows creating more complex groupings of content for use in channels.
Custom Shows are akin to classic playlists. Any ordering operations in scheduling tools will use the ordering as defined in the Custom Show. Custom Shows can be schedule as if they were "regular" shows in slot editors. Content within are grouped by the Custom Show and not their _actual_ show. This allows creating more complex groupings of content for use in channels.
## External Sync
Custom Shows can be linked to an external playlist from a connected media source. When synced, the custom show's content is automatically kept in sync with the upstream playlist.
### Supported Sources
| Source | Playlist Type |
|--------|--------------|
| Plex | Playlists |
### Setting Up Sync
When creating or editing a Custom Show:
1. Enable the **Sync with external playlist** toggle.
2. Select a **Media Source** (currently Plex only).
3. Select the **Playlist** from that media source to sync with.
4. Save the Custom Show.
An initial sync runs immediately when a synced Custom Show is first created. After that, syncs run automatically on the same schedule as media source library refreshes (configured in Settings under the media source rescan interval).
### Manual Sync
On an existing synced Custom Show, click **Sync Now** to trigger an immediate sync. The last sync time is displayed next to the button.
### Behavior
- While a Custom Show is linked to an external playlist, its content is **read-only** — the "Add Media" button and drag-to-reorder controls are hidden.
- To manage content manually, disable the sync toggle and save.
- If the upstream playlist is empty at sync time, the Custom Show's existing content is left unchanged and a warning is logged.
- Deleting the linked media source from Tunarr sets the sync reference to `null`; the Custom Show retains its last-synced content.

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { CustomShowDB } from './db/CustomShowDB.ts';
import { FillerDB } from './db/FillerListDB.ts';
import { SmartCollectionsDB } from './db/SmartCollectionsDB.ts';
import { CustomShowSyncService } from './services/CustomShowSyncService.ts';
import { TranscodeConfigDB } from './db/TranscodeConfigDB.ts';
import { ProgramConverter } from './db/converters/ProgramConverter.ts';
import { MediaSourceDB } from './db/mediaSourceDB.ts';
@@ -91,6 +92,9 @@ export class ServerContext {
@inject(SmartCollectionsDB)
public readonly smartCollectionsDB!: SmartCollectionsDB;
@inject(CustomShowSyncService)
public readonly customShowSyncService!: CustomShowSyncService;
}
export class ServerRequestContext {

View File

@@ -7,10 +7,11 @@ import {
UpdateCustomShowRequestSchema,
} from '@tunarr/types/api';
import { CustomProgramSchema, CustomShowSchema } from '@tunarr/types/schemas';
import { isNil, isNull, map, sumBy } from 'lodash-es';
import { isNil, isNull, isNumber, sumBy } from 'lodash-es';
import { z } from 'zod/v4';
import { MaterializeProgramsCommand } from '../commands/MaterializeProgramsCommand.ts';
import { container } from '../container.ts';
import { parseFloatOrNull } from '../util/index.ts';
// eslint-disable-next-line @typescript-eslint/require-await
export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
@@ -38,11 +39,18 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
const customShows = await req.serverCtx.customShowDB.getAllShowsInfo();
return res.send(
map(customShows, (cs) => ({
customShows.map((cs) => ({
id: cs.id,
name: cs.name,
contentCount: cs.count,
totalDuration: cs.totalDuration,
totalDuration: isNumber(cs.totalDuration)
? cs.totalDuration
: (parseFloatOrNull(cs.totalDuration) ?? 0),
syncMediaSourceId: cs.syncMediaSourceId ?? undefined,
syncMediaSourceType: cs.syncMediaSourceType ?? undefined,
syncExternalPlaylistId: cs.syncExternalPlaylistId ?? undefined,
lastSyncedAt: cs.lastSyncedAt?.getTime() ?? undefined,
isSyncing: req.serverCtx.customShowSyncService.isShowSyncing(cs.id),
})),
);
},
@@ -72,10 +80,17 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
return res.status(200).send({
id: customShow.uuid,
name: customShow.name,
contentCount: customShow.customShowContent.length,
contentCount: customShow.content.length,
totalDuration: sumBy(
customShow.customShowContent,
(c) => c.duration ?? 0,
customShow.content,
({ program }) => program.duration ?? 0,
),
syncMediaSourceId: customShow.syncMediaSourceId ?? undefined,
syncMediaSourceType: customShow.syncMediaSourceType ?? undefined,
syncExternalPlaylistId: customShow.syncExternalPlaylistId ?? undefined,
lastSyncedAt: customShow.lastSyncedAt?.getTime() ?? undefined,
isSyncing: req.serverCtx.customShowSyncService.isShowSyncing(
customShow.uuid,
),
});
},
@@ -107,10 +122,17 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
return res.status(200).send({
id: customShow.uuid,
name: customShow.name,
contentCount: customShow.customShowContent.length,
contentCount: customShow.content.length,
totalDuration: sumBy(
customShow.customShowContent,
(c) => c.duration ?? 0,
customShow.content,
({ program }) => program.duration ?? 0,
),
syncMediaSourceId: customShow.syncMediaSourceId ?? undefined,
syncMediaSourceType: customShow.syncMediaSourceType ?? undefined,
syncExternalPlaylistId: customShow.syncExternalPlaylistId ?? undefined,
lastSyncedAt: customShow.lastSyncedAt?.getTime() ?? undefined,
isSyncing: req.serverCtx.customShowSyncService.isShowSyncing(
customShow.uuid,
),
});
},
@@ -172,6 +194,16 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
},
async (req, res) => {
const newId = await req.serverCtx.customShowDB.createShow(req.body);
// If this is a synced custom show, trigger an immediate sync
if (req.body.syncMediaSourceId && req.body.syncExternalPlaylistId) {
try {
await req.serverCtx.customShowSyncService.syncShow(newId);
} catch (e) {
logger.error(e, 'Failed initial sync for new custom show %s', newId);
}
}
const newShow = await req.serverCtx.customShowDB.getShow(newId);
if (!newShow) {
throw new Error(
@@ -182,8 +214,18 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
return res.status(201).send({
id: newShow.uuid,
name: newShow.name,
contentCount: newShow.customShowContent.length,
totalDuration: sumBy(newShow.customShowContent, (c) => c.duration ?? 0),
contentCount: newShow.content.length,
totalDuration: sumBy(
newShow.content,
({ program }) => program.duration ?? 0,
),
syncMediaSourceId: newShow.syncMediaSourceId ?? undefined,
syncMediaSourceType: newShow.syncMediaSourceType ?? undefined,
syncExternalPlaylistId: newShow.syncExternalPlaylistId ?? undefined,
lastSyncedAt: newShow.lastSyncedAt?.getTime() ?? undefined,
isSyncing: req.serverCtx.customShowSyncService.isShowSyncing(
newShow.uuid,
),
});
},
);
@@ -216,4 +258,56 @@ export const customShowsApiV2: RouterPluginAsyncCallback = async (fastify) => {
return res.status(200).send({ id: req.params.id });
},
);
fastify.post(
'/custom-shows/:id/sync',
{
schema: {
tags: ['Custom Shows'],
operationId: 'syncCustomShow',
description:
'Triggers an immediate sync for a custom show linked to an external playlist',
params: IdPathParamSchema,
response: {
200: CustomShowSchema,
400: z.object({ error: z.string() }),
404: z.void(),
},
},
},
async (req, res) => {
const show = await req.serverCtx.customShowDB.getShow(req.params.id);
if (isNil(show)) {
return res.status(404).send();
}
if (!show.syncMediaSourceId || !show.syncExternalPlaylistId) {
return res
.status(400)
.send({ error: 'Custom show is not configured for sync' });
}
await req.serverCtx.customShowSyncService.syncShow(show.uuid);
const updatedShow = await req.serverCtx.customShowDB.getShow(show.uuid);
if (!updatedShow) {
throw new Error('Show disappeared after sync');
}
return res.status(200).send({
id: updatedShow.uuid,
name: updatedShow.name,
contentCount: updatedShow.content.length,
totalDuration: sumBy(
updatedShow.content,
({ program }) => program.duration ?? 0,
),
syncMediaSourceId: updatedShow.syncMediaSourceId ?? undefined,
syncMediaSourceType: updatedShow.syncMediaSourceType ?? undefined,
syncExternalPlaylistId: updatedShow.syncExternalPlaylistId ?? undefined,
lastSyncedAt: updatedShow.lastSyncedAt?.getTime() ?? undefined,
});
},
);
};

View File

@@ -164,6 +164,8 @@ export class GetMaterializedChannelScheduleCommand {
? {
...customShow,
id: customShow.uuid,
lastSyncedAt: customShow.lastSyncedAt?.getTime(),
isSyncing: false,
}
: null,
isMissing: !customShow,
@@ -311,6 +313,8 @@ export class GetMaterializedChannelScheduleCommand {
? {
...customShow,
id: customShow.uuid,
lastSyncedAt: customShow.lastSyncedAt?.getTime(),
isSyncing: false,
}
: null,
isMissing: !customShow,

View File

@@ -993,22 +993,6 @@ export class ChannelDB implements IChannelDB {
.where('channelPrograms.channelUuid', '=', channel.uuid),
)
.executeTakeFirstOrThrow();
// Copy custom shows
await tx
.insertInto('channelCustomShows')
.columns(['channelUuid', 'customShowUuid'])
.expression((eb) =>
eb
.selectFrom('channelCustomShows')
.select([
eb.val(newChannelId).as('channelUuid'),
'channelCustomShows.customShowUuid',
])
.where('channelCustomShows.channelUuid', '=', channel.uuid),
)
.executeTakeFirstOrThrow();
return newChannel;
});

View File

@@ -1,5 +1,5 @@
import { KEYS } from '@/types/inject.js';
import { isNonEmptyString } from '@/util/index.js';
import { isNonEmptyString, parseFloatOrNull } from '@/util/index.js';
import { createExternalId } from '@tunarr/shared';
import {
ContentProgram,
@@ -12,19 +12,23 @@ import {
UpdateCustomShowRequest,
} from '@tunarr/types/api';
import dayjs from 'dayjs';
import { count, eq, sum } from 'drizzle-orm';
import { inject, injectable } from 'inversify';
import { Kysely } from 'kysely';
import { chunk, isNil, orderBy, partition, uniqBy } from 'lodash-es';
import { MarkRequired } from 'ts-essentials';
import { v4 } from 'uuid';
import { IProgramDB } from './interfaces/IProgramDB.ts';
import { withCustomShowPrograms } from './programQueryHelpers.ts';
import { MediaSourceId, MediaSourceType } from './schema/base.ts';
import type { NewCustomShow } from './schema/CustomShow.ts';
import type { NewCustomShowContent } from './schema/CustomShowContent.ts';
import { CustomShow, type NewCustomShow } from './schema/CustomShow.ts';
import {
CustomShowContent,
type NewCustomShowContent,
} from './schema/CustomShowContent.ts';
import { DB } from './schema/db.ts';
import { ProgramWithRelationsOrm } from './schema/derivedTypes.ts';
import { DrizzleDBAccess } from './schema/index.ts';
import { Program } from './schema/Program.ts';
@injectable()
export class CustomShowDB {
@@ -35,16 +39,21 @@ export class CustomShowDB {
) {}
async getShow(id: string) {
return this.db
.selectFrom('customShow')
.selectAll()
.where('customShow.uuid', '=', id)
.select((eb) =>
withCustomShowPrograms(eb, {
fields: ['program.uuid', 'program.duration'],
}),
)
.executeTakeFirst();
return await this.drizzle.query.customShow.findFirst({
where: (fields, { eq }) => eq(fields.uuid, id),
with: {
content: {
with: {
program: {
columns: {
uuid: true,
duration: true,
},
},
},
},
},
});
}
async getShows(ids: string[]) {
@@ -121,12 +130,32 @@ export class CustomShowDB {
await this.upsertCustomShowContent(show.uuid, updateRequest.programs);
}
const updates: Partial<NewCustomShow> = {};
if (updateRequest.name) {
updates.name = updateRequest.name;
}
if (!updateRequest.enableSync) {
updates.syncExternalPlaylistId = null;
updates.syncMediaSourceId = null;
updates.syncMediaSourceType = null;
} else {
updates.syncMediaSourceId = updateRequest.syncMediaSourceId ?? null;
updates.syncMediaSourceType = updateRequest.syncMediaSourceType ?? null;
updates.syncExternalPlaylistId =
updateRequest.syncExternalPlaylistId ?? null;
}
if (Object.keys(updates).length > 0) {
await this.db
.updateTable('customShow')
.where('uuid', '=', show.uuid)
.limit(1)
.set({ name: updateRequest.name })
.set({
...updates,
// Do not allow clients to set this.
lastSyncedAt: undefined,
})
.execute();
}
@@ -140,11 +169,16 @@ export class CustomShowDB {
createdAt: now,
updatedAt: now,
name: createRequest.name,
syncMediaSourceId: createRequest.syncMediaSourceId ?? null,
syncMediaSourceType: createRequest.syncMediaSourceType ?? null,
syncExternalPlaylistId: createRequest.syncExternalPlaylistId ?? null,
} satisfies NewCustomShow;
await this.db.insertInto('customShow').values(show).execute();
await this.upsertCustomShowContent(show.uuid, createRequest.programs);
if (createRequest.programs.length > 0) {
await this.upsertCustomShowContent(show.uuid, createRequest.programs);
}
return show.uuid;
}
@@ -156,11 +190,6 @@ export class CustomShowDB {
}
await this.db.transaction().execute(async (tx) => {
// TODO: Do this deletion in the DB with foreign keys.
await tx
.deleteFrom('channelCustomShows')
.where('customShowUuid', '=', show.uuid)
.execute();
await tx
.deleteFrom('customShowContent')
.where('customShowContent.customShowUuid', '=', show.uuid)
@@ -184,39 +213,61 @@ export class CustomShowDB {
}
async getAllShowsInfo() {
const showsAndContentCount = await this.db
.selectFrom('customShow')
.selectAll('customShow')
.innerJoin(
'customShowContent',
'customShow.uuid',
'customShowContent.customShowUuid',
const showsAndContentCount = await this.drizzle
.select({
customShow: CustomShow,
contentCount: count(CustomShowContent.contentUuid),
totalDuration: sum(
this.drizzle
.select({ duration: Program.duration })
.from(Program)
.where(eq(Program.uuid, CustomShowContent.contentUuid)),
),
})
.from(CustomShow)
.leftJoin(
CustomShowContent,
eq(CustomShow.uuid, CustomShowContent.customShowUuid),
)
.groupBy('customShow.uuid')
.select((eb) => [
eb.fn.count<number>('customShowContent.contentUuid').as('contentCount'),
eb.fn
.sum<number>(
eb
.selectFrom('program')
.whereRef('program.uuid', '=', 'customShowContent.contentUuid')
.select('duration'),
)
.as('totalDuration'),
])
.execute();
return showsAndContentCount.map((f) => ({
id: f.uuid,
name: f.name,
count: f.contentCount,
totalDuration: f.totalDuration,
}));
.groupBy(CustomShow.uuid);
return showsAndContentCount.map(
({ customShow, totalDuration, contentCount }) => ({
id: customShow.uuid,
name: customShow.name,
count: contentCount,
totalDuration: totalDuration
? (parseFloatOrNull(totalDuration) ?? 0)
: 0,
syncMediaSourceId: customShow.syncMediaSourceId,
syncMediaSourceType: customShow.syncMediaSourceType,
syncExternalPlaylistId: customShow.syncExternalPlaylistId,
lastSyncedAt: customShow.lastSyncedAt,
}),
);
}
private async upsertCustomShowContent(
async getSyncedShows() {
return this.db
.selectFrom('customShow')
.selectAll()
.where('syncMediaSourceId', 'is not', null)
.where('syncExternalPlaylistId', 'is not', null)
.execute();
}
async updateLastSyncedAt(id: string) {
await this.db
.updateTable('customShow')
.where('uuid', '=', id)
.set({ lastSyncedAt: +dayjs() })
.execute();
}
async upsertCustomShowContent(
customShowId: string,
programs: ContentProgram[],
) {
): Promise<void> {
if (programs.length === 0) {
return;
}

View File

@@ -28,6 +28,7 @@ import {
isEpisodeWithHierarchy,
isMusicTrackWithHierarchy,
MusicTrackWithHierarchy,
tag,
untag,
} from '@tunarr/types';
import { isValidSingleExternalIdType } from '@tunarr/types/schemas';
@@ -1062,6 +1063,9 @@ export class ProgramDB implements IProgramDB {
(lib) => lib.uuid,
);
const seenUniqueExternalSourceId = new Set<string>();
const seenUniqueMediaSourceId = new Set<string>();
const programsToPersist: MintedNewProgramInfo[] = seq.collect(
contentPrograms,
(p) => {
@@ -1090,6 +1094,27 @@ export class ProgramDB implements IProgramDB {
return;
}
const mediaSourceIdUnique = programExternalIdString(program.program);
if (seenUniqueMediaSourceId.has(mediaSourceIdUnique)) {
return;
} else {
seenUniqueMediaSourceId.add(mediaSourceIdUnique);
}
const externalSourceIdUnique =
program.program.sourceType === 'local'
? program.program.externalKey
: createExternalId(
program.program.sourceType,
tag(program.program.externalSourceId),
program.program.externalKey,
);
if (seenUniqueExternalSourceId.has(externalSourceIdUnique)) {
return;
} else {
seenUniqueExternalSourceId.add(externalSourceIdUnique);
}
const externalIds = program.externalIds;
return { program, externalIds, apiProgram: p };
},
@@ -1108,34 +1133,45 @@ export class ProgramDB implements IProgramDB {
programsToPersist,
programUpsertBatchSize,
)) {
upsertedPrograms.push(
...(await this.db.transaction().execute((tx) =>
tx
.insertInto('program')
.values(chunkToInsert.map(({ program: { program } }) => program))
// .onConflict((oc) =>
// oc
// .columns(['sourceType', 'externalSourceId', 'externalKey'])
// .doUpdateSet((eb) =>
// mapToObj(ProgramUpsertFields, (f) => ({
// [f.replace('excluded.', '')]: eb.ref(f),
// })),
// ),
// )
.onConflict((oc) =>
oc
.columns(['sourceType', 'mediaSourceId', 'externalKey'])
.doUpdateSet((eb) =>
mapToObj(ProgramUpsertFields, (f) => ({
[f.replace('excluded.', '')]: eb.ref(f),
})),
),
)
.returningAll()
.$narrowType<{ mediaSourceId: NotNull }>()
.execute(),
)),
const programsToUpsert = chunkToInsert.map(
({ program: { program } }) => program,
);
try {
upsertedPrograms.push(
...(await this.db.transaction().execute((tx) =>
tx
.insertInto('program')
.values(programsToUpsert)
// .onConflict((oc) =>
// oc
// .columns(['sourceType', 'externalSourceId', 'externalKey'])
// .doUpdateSet((eb) =>
// mapToObj(ProgramUpsertFields, (f) => ({
// [f.replace('excluded.', '')]: eb.ref(f),
// })),
// ),
// )
.onConflict((oc) =>
oc
.columns(['sourceType', 'mediaSourceId', 'externalKey'])
.doUpdateSet((eb) =>
mapToObj(ProgramUpsertFields, (f) => ({
[f.replace('excluded.', '')]: eb.ref(f),
})),
),
)
.returningAll()
.$narrowType<{ mediaSourceId: NotNull }>()
.execute(),
)),
);
} catch (e) {
this.logger.error(
e,
'Error while inserting batch %j',
programsToUpsert,
);
}
}
});
@@ -1912,35 +1948,41 @@ export class ProgramDB implements IProgramDB {
singleIdPromise = mapAsyncSeq(
chunk(singles, chunkSize),
(singleChunk) => {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(singleChunk.map(toInsertableProgramExternalId))
// .onConflict((oc) =>
// oc
// .columns(['programUuid', 'sourceType', 'externalSourceId'])
// .where('externalSourceId', 'is', null)
// .doUpdateSet((eb) => ({
// updatedAt: eb.ref('excluded.updatedAt'),
// externalFilePath: eb.ref('excluded.externalFilePath'),
// directFilePath: eb.ref('excluded.directFilePath'),
// programUuid: eb.ref('excluded.programUuid'),
// })),
// )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
const eids = singleChunk.map(toInsertableProgramExternalId);
try {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(eids)
// .onConflict((oc) =>
// oc
// .columns(['programUuid', 'sourceType', 'externalSourceId'])
// .where('externalSourceId', 'is', null)
// .doUpdateSet((eb) => ({
// updatedAt: eb.ref('excluded.updatedAt'),
// externalFilePath: eb.ref('excluded.externalFilePath'),
// directFilePath: eb.ref('excluded.directFilePath'),
// programUuid: eb.ref('excluded.programUuid'),
// })),
// )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType'])
.where('mediaSourceId', 'is', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
} catch (e) {
this.logger.error(e, 'Failed to upsert eids: %j', eids);
throw e;
}
},
).then(flatten);
} else {
@@ -1952,35 +1994,41 @@ export class ProgramDB implements IProgramDB {
multiIdPromise = mapAsyncSeq(
chunk(multiples, chunkSize),
(multiChunk) => {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(multiChunk.map(toInsertableProgramExternalId))
// .onConflict((oc) =>
// oc
// .columns(['programUuid', 'sourceType', 'externalSourceId'])
// .where('externalSourceId', 'is not', null)
// .doUpdateSet((eb) => ({
// updatedAt: eb.ref('excluded.updatedAt'),
// externalFilePath: eb.ref('excluded.externalFilePath'),
// directFilePath: eb.ref('excluded.directFilePath'),
// programUuid: eb.ref('excluded.programUuid'),
// })),
// )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
const eids = multiChunk.map(toInsertableProgramExternalId);
try {
return this.db.transaction().execute((tx) =>
tx
.insertInto('programExternalId')
.values(eids)
// .onConflict((oc) =>
// oc
// .columns(['programUuid', 'sourceType', 'externalSourceId'])
// .where('externalSourceId', 'is not', null)
// .doUpdateSet((eb) => ({
// updatedAt: eb.ref('excluded.updatedAt'),
// externalFilePath: eb.ref('excluded.externalFilePath'),
// directFilePath: eb.ref('excluded.directFilePath'),
// programUuid: eb.ref('excluded.programUuid'),
// })),
// )
.onConflict((oc) =>
oc
.columns(['programUuid', 'sourceType', 'mediaSourceId'])
.where('mediaSourceId', 'is not', null)
.doUpdateSet((eb) => ({
updatedAt: eb.ref('excluded.updatedAt'),
externalFilePath: eb.ref('excluded.externalFilePath'),
directFilePath: eb.ref('excluded.directFilePath'),
programUuid: eb.ref('excluded.programUuid'),
})),
)
.returningAll()
.execute(),
);
} catch (e) {
this.logger.error(e, 'Error while inserting ID batch: %j', eids);
throw e;
}
},
).then(flatten);
} else {

View File

@@ -16,7 +16,6 @@ import {
type ChannelTranscodingSettings,
type ChannelWatermark,
} from './base.ts';
import { ChannelCustomShow } from './ChannelCustomShow.ts';
import { ChannelFillerShow } from './ChannelFillerShow.ts';
import { ChannelPrograms } from './ChannelPrograms.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
@@ -77,7 +76,6 @@ export type ChannelOrm = InferSelectModel<typeof Channel>;
export const ChannelRelations = relations(Channel, ({ many, one }) => ({
channelPrograms: many(ChannelPrograms),
channelCustomShows: many(ChannelCustomShow),
channelFillerShow: many(ChannelFillerShow),
playHistory: many(ProgramPlayHistory),
transcodeConfig: one(TranscodeConfig, {

View File

@@ -1,38 +0,0 @@
import { relations } from 'drizzle-orm';
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Selectable } from 'kysely';
import { Channel } from './Channel.ts';
import { CustomShow } from './CustomShow.ts';
import type { KyselifyBetter } from './KyselifyBetter.ts';
export const ChannelCustomShow = sqliteTable(
'channel_custom_show',
{
channelUuid: text()
.notNull()
.references(() => Channel.uuid, { onDelete: 'cascade' }),
customShowUuid: text()
.notNull()
.references(() => CustomShow.uuid, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.channelUuid, table.customShowUuid] }),
],
);
export type ChannelCustomShowsTable = KyselifyBetter<typeof ChannelCustomShow>;
export type ChannelCustomShows = Selectable<ChannelCustomShowsTable>;
export const ChannelCustomShowRelations = relations(
ChannelCustomShow,
({ one }) => ({
channel: one(Channel, {
fields: [ChannelCustomShow.channelUuid],
references: [Channel.uuid],
}),
customShow: one(CustomShow, {
fields: [ChannelCustomShow.customShowUuid],
references: [CustomShow.uuid],
}),
}),
);

View File

@@ -1,15 +1,21 @@
import { relations } from 'drizzle-orm';
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { Insertable, Selectable } from 'kysely';
import { ChannelCustomShow } from './ChannelCustomShow.ts';
import { CustomShowContent } from './CustomShowContent.ts';
import { type KyselifyBetter } from './KyselifyBetter.ts';
import { MediaSource } from './MediaSource.ts';
export const CustomShow = sqliteTable('custom_show', {
uuid: text().primaryKey(),
createdAt: integer(),
updatedAt: integer(),
name: text().notNull(),
syncMediaSourceId: text().references(() => MediaSource.uuid, {
onDelete: 'set null',
}),
syncMediaSourceType: text().$type<'plex'>(),
syncExternalPlaylistId: text(),
lastSyncedAt: integer({ mode: 'timestamp_ms' }),
});
export type CustomShowTable = KyselifyBetter<typeof CustomShow>;
@@ -17,6 +23,5 @@ export type CustomShow = Selectable<CustomShowTable>;
export type NewCustomShow = Insertable<CustomShowTable>;
export const CustomShowRelations = relations(CustomShow, ({ many }) => ({
channelCustomShows: many(ChannelCustomShow),
content: many(CustomShowContent),
}));

View File

@@ -1,6 +1,5 @@
import type { CachedImageTable } from './CachedImage.js';
import type { ChannelTable } from './Channel.ts';
import type { ChannelCustomShowsTable } from './ChannelCustomShow.ts';
import type { ChannelFallbackTable } from './ChannelFallback.ts';
import type { ChannelFillerShowTable } from './ChannelFillerShow.ts';
import type { ChannelProgramsTable } from './ChannelPrograms.ts';
@@ -33,7 +32,6 @@ export interface DB {
channelPrograms: ChannelProgramsTable;
channelSubtitlePreferences: ChannelSubtitlePreferencesTable;
channelFallback: ChannelFallbackTable;
channelCustomShows: ChannelCustomShowsTable;
channelFillerShow: ChannelFillerShowTable;
customShow: CustomShowTable;
customShowContent: CustomShowContentTable;

View File

@@ -1,10 +1,7 @@
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { Artwork, ArtworkRelations } from './Artwork.ts';
import { Channel, ChannelRelations } from './Channel.ts';
import {
ChannelCustomShow,
ChannelCustomShowRelations,
} from './ChannelCustomShow.ts';
import {
ChannelFallback,
ChannelFallbackRelations,
@@ -99,8 +96,6 @@ export const schema = {
channels: Channel,
channelRelations: ChannelRelations,
channelPrograms: ChannelPrograms,
channelCustomShows: ChannelCustomShow,
channelCustomShowRelations: ChannelCustomShowRelations,
channelFallback: ChannelFallback,
channelFallbackRelations: ChannelFallbackRelations,
channelFillerShow: ChannelFillerShow,

View File

@@ -1748,6 +1748,42 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
this.plexArtworkInject(plexAlbum.thumb, 'poster'),
this.plexArtworkInject(plexAlbum.art, 'banner'),
]),
artist: plexAlbum.parentRatingKey
? ({
externalId: plexAlbum.parentRatingKey,
identifiers: compact([
plexAlbum.parentRatingKey
? {
id: plexAlbum.parentRatingKey,
type: 'plex',
sourceId: this.options.mediaSource.uuid,
}
: null,
plexAlbum.parentGuid
? {
id: plexAlbum.parentGuid,
type: 'plex-guid',
}
: null,
]),
mediaSourceId: this.options.mediaSource.uuid,
libraryId: mediaLibrary.uuid,
plot: null,
sourceType: 'plex',
title: plexAlbum.parentTitle ?? '',
sortTitle: plexAlbum.parentTitle
? titleToSortTitle(plexAlbum.parentTitle)
: '',
summary: null,
tagline: null,
tags: [],
uuid: v4(),
type: 'artist',
canonicalId: '???',
genres: [],
artwork: [],
} satisfies PlexArtist)
: undefined,
});
}

View File

@@ -204,6 +204,9 @@ export class DirectMigrationProvider implements MigrationProvider {
migration1773603770: makeKyselyMigrationFromSqlFile(
'./sql/0042_supreme_medusa.sql',
),
migration1775060606: makeKyselyMigrationFromSqlFile(
'./sql/0043_common_zzzax.sql',
),
},
wrapWithTransaction,
),

View File

@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS `channel_fallback` (
`channel_uuid` text NOT NULL,
`program_uuid` text NOT NULL,
PRIMARY KEY(`channel_uuid`, `program_uuid`),
FOREIGN KEY (`channel_uuid`) REFERENCES `channel`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`program_uuid`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
DROP TABLE IF EXISTS `channel_custom_show`;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_custom_show_content` (
`content_uuid` text NOT NULL,
`custom_show_uuid` text NOT NULL,
`index` integer NOT NULL,
PRIMARY KEY(`content_uuid`, `custom_show_uuid`, `index`),
FOREIGN KEY (`content_uuid`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`custom_show_uuid`) REFERENCES `custom_show`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`content_uuid`) REFERENCES `program`(`uuid`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`custom_show_uuid`) REFERENCES `custom_show`(`uuid`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_custom_show_content`("content_uuid", "custom_show_uuid", "index") SELECT "content_uuid", "custom_show_uuid", "index" FROM `custom_show_content`;--> statement-breakpoint
DROP TABLE `custom_show_content`;--> statement-breakpoint
ALTER TABLE `__new_custom_show_content` RENAME TO `custom_show_content`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `custom_show` ADD `sync_media_source_id` text REFERENCES media_source(uuid);--> statement-breakpoint
ALTER TABLE `custom_show` ADD `sync_media_source_type` text;--> statement-breakpoint
ALTER TABLE `custom_show` ADD `sync_external_playlist_id` text;--> statement-breakpoint
ALTER TABLE `custom_show` ADD `last_synced_at` integer;

File diff suppressed because it is too large Load Diff

View File

@@ -302,6 +302,13 @@
"when": 1773603770514,
"tag": "0042_supreme_medusa",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1775060587475,
"tag": "0043_common_zzzax",
"breakpoints": true
}
]
}
}

View File

@@ -0,0 +1,144 @@
import { CustomShowDB } from '@/db/CustomShowDB.js';
import type { MediaSourceId } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { KEYS } from '@/types/inject.js';
import { Logger } from '@/util/logging/LoggerFactory.js';
import { MutexMap } from '@/util/mutexMap.js';
import { ApiProgramMinter } from '@tunarr/shared';
import { seq } from '@tunarr/shared/util';
import { isTerminalItemType, tag, type ContentProgram } from '@tunarr/types';
import { inject, injectable } from 'inversify';
import { PlexHierarchyTraversal } from './PlexItemEnumerator.ts';
@injectable()
export class CustomShowSyncService {
constructor(
@inject(KEYS.Logger) private logger: Logger,
@inject(CustomShowDB) private customShowDB: CustomShowDB,
@inject(MediaSourceApiFactory)
private mediaSourceApiFactory: MediaSourceApiFactory,
@inject(KEYS.MutexMap) private locks: MutexMap,
) {}
async syncAll(): Promise<void> {
const syncedShows = await this.customShowDB.getSyncedShows();
if (syncedShows.length === 0) {
this.logger.debug('No synced custom shows to process');
return;
}
this.logger.info('Syncing %d custom show(s)', syncedShows.length);
for (const show of syncedShows) {
try {
await this.locks.runWithLockId(show.uuid, () =>
this.syncShow(show.uuid),
);
} catch (e) {
this.logger.error(
e,
'Failed to sync custom show %s (%s)',
show.name,
show.uuid,
);
}
}
}
async syncShow(customShowId: string): Promise<void> {
const show = await this.customShowDB.getShow(customShowId);
if (!show) {
throw new Error(`Custom show ${customShowId} not found`);
}
if (
!show.syncMediaSourceId ||
!show.syncMediaSourceType ||
!show.syncExternalPlaylistId
) {
throw new Error(`Custom show ${show.name} is not configured for sync`);
}
this.logger.info(
'Syncing custom show "%s" from %s playlist %s',
show.name,
show.syncMediaSourceType,
show.syncExternalPlaylistId,
);
const programs = await this.fetchPlaylistPrograms(
show.syncMediaSourceType,
tag<MediaSourceId>(show.syncMediaSourceId),
show.syncExternalPlaylistId,
);
if (programs.length > 0) {
await this.customShowDB.upsertCustomShowContent(show.uuid, programs);
} else {
this.logger.warn(
'Got 0 items from external playlist (type = %s id = %s)',
show.syncMediaSourceType,
show.syncExternalPlaylistId,
);
}
await this.customShowDB.updateLastSyncedAt(show.uuid);
this.logger.info(
'Synced custom show "%s" with %d program(s)',
show.name,
programs.length,
);
}
isShowSyncing(id: string) {
return this.locks.isLocked(id);
}
private async fetchPlaylistPrograms(
sourceType: string,
mediaSourceId: MediaSourceId,
playlistId: string,
): Promise<ContentProgram[]> {
switch (sourceType) {
case 'plex':
return this.fetchPlexPlaylistPrograms(mediaSourceId, playlistId);
default:
throw new Error(`Unsupported sync source type: ${sourceType}`);
}
}
private async fetchPlexPlaylistPrograms(
mediaSourceId: MediaSourceId,
playlistId: string,
): Promise<ContentProgram[]> {
const client =
await this.mediaSourceApiFactory.getPlexApiClientById(mediaSourceId);
if (!client) {
throw new Error(
`Could not get Plex client for media source ${mediaSourceId}`,
);
}
const result = await client.getItemChildren(playlistId, 'playlist');
const items = result.getOrThrow();
const allPlaylistItems = seq.collect(items, (item) => {
if (!isTerminalItemType(item)) {
return null;
}
return item;
});
const expandedItems = await new PlexHierarchyTraversal(
client,
).expandAncestors(allPlaylistItems);
return seq.collect(expandedItems, (item) =>
ApiProgramMinter.mintProgram2(item),
);
}
}

View File

@@ -2,43 +2,37 @@ import type { PlexApiClient } from '@/external/plex/PlexApiClient.js';
import { flatMapAsyncSeq } from '@/util/index.js';
import type { Logger } from '@/util/logging/LoggerFactory.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { isNonEmptyString } from '@tunarr/shared/util';
import type { ProgramLike } from '@tunarr/types';
import {
isTerminalItemType,
type Library,
type ProgramOrFolder,
type TerminalProgram,
} from '@tunarr/types';
import type { PlexTerminalMedia } from '@tunarr/types/plex';
import { flatten, flattenDeep, map, uniqBy } from 'lodash-es';
import { match, P } from 'ts-pattern';
import type { MediaSourceOrm } from '../db/schema/MediaSource.ts';
import { asyncPool, unfurlPool } from '../util/asyncPool.ts';
export type EnrichedPlexTerminalMedia = PlexTerminalMedia & {
id?: string;
};
export class PlexItemEnumerator {
#logger: Logger = LoggerFactory.child({ className: PlexItemEnumerator.name });
export class PlexHierarchyTraversal {
#logger: Logger = LoggerFactory.child({
className: PlexHierarchyTraversal.name,
});
constructor(private plex: PlexApiClient) {}
async enumerateItems(
mediaSource: MediaSourceOrm,
initialItems: (ProgramOrFolder | Library)[],
) {
async expandDescendants(initialItems: (ProgramOrFolder | Library)[]) {
this.#logger.debug(
'enumerating items: %O',
map(initialItems, (item) => item.externalId),
);
const allItems = await flatMapAsyncSeq(initialItems, (item) =>
this.enumerateItem(mediaSource, item),
this.expandItemDescendants(item),
);
return uniqBy(allItems, (item) => item.externalId);
}
async enumerateItem(
mediaSource: MediaSourceOrm,
async expandItemDescendants(
item: ProgramOrFolder | Library,
parent?: ProgramOrFolder | Library,
acc: TerminalProgram[] = [],
@@ -72,7 +66,7 @@ export class PlexItemEnumerator {
.then(async (result) => {
const pool = asyncPool(
result.getOrThrow(),
(nextItem) => this.enumerateItem(mediaSource, nextItem, item, acc),
(nextItem) => this.expandItemDescendants(nextItem, item, acc),
{ concurrency: 3 },
);
return flatten(await unfurlPool(pool));
@@ -80,4 +74,145 @@ export class PlexItemEnumerator {
.then((allResults) => flattenDeep(allResults));
}
}
async expandAncestors(items: TerminalProgram[]): Promise<TerminalProgram[]> {
const seenItems = new Map<string, ProgramLike>();
const pool = asyncPool(
items,
(item) => this.expandItemAncestorsImpl(item, seenItems),
{ concurrency: 3 },
);
return await unfurlPool(pool);
}
async expandItemAncestors(item: TerminalProgram): Promise<TerminalProgram> {
return this.expandItemAncestorsImpl(item, new Map());
}
private async expandItemAncestorsImpl(
item: TerminalProgram,
seenItems: Map<string, ProgramLike>,
): Promise<TerminalProgram> {
return match(item)
.with({ type: P.union('movie', 'music_video', 'other_video') }, (m) =>
Promise.resolve(m),
)
.with({ type: 'episode' }, async (ep) => {
const seasonId = ep.season?.externalId;
if (isNonEmptyString(seasonId)) {
const existing = seenItems.get(seasonId);
if (existing?.type === 'season') {
ep.season = existing;
} else {
const seasonResult = await this.plex.getSeason(seasonId);
seasonResult.either(
(season) => {
seenItems.set(season.externalId, season);
ep.season = season;
},
(err) => {
this.#logger.error(
err,
'Error querying Plex season %s for episode %s',
seasonId,
ep.externalId,
);
},
);
}
}
const showId = ep.show?.externalId ?? ep.season?.show?.externalId;
if (isNonEmptyString(showId)) {
const existing = seenItems.get(showId);
if (existing?.type === 'show') {
ep.show = existing;
if (ep.season) {
ep.season.show = existing;
}
} else {
const showResult = await this.plex.getShow(showId);
showResult.either(
(show) => {
ep.show = show;
if (ep.season) {
ep.season.show = show;
}
seenItems.set(show.externalId, show);
},
(err) => {
this.#logger.error(
err,
'Error querying Plex show %s for episode %s',
showId,
ep.externalId,
);
},
);
}
}
return ep;
})
.with({ type: 'track' }, async (track) => {
const albumId = track.album?.externalId;
const artistId =
track.artist?.externalId ?? track.album?.artist?.externalId;
if (isNonEmptyString(albumId)) {
const existing = seenItems.get(albumId);
if (existing?.type === 'album') {
track.album = existing;
} else {
const albumResult = await this.plex.getMusicAlbum(albumId);
albumResult.either(
(album) => {
track.album = album;
seenItems.set(album.externalId, album);
},
(err) => {
this.#logger.error(
err,
'Error querying Plex album %s for track %s',
albumId,
track.externalId,
);
},
);
}
}
console.log('artist id', artistId);
if (isNonEmptyString(artistId)) {
const existing = seenItems.get(artistId);
if (existing?.type === 'artist') {
track.artist = existing;
if (track.album) {
track.album.artist = existing;
}
} else {
const artistResult = await this.plex.getMusicArtist(artistId);
artistResult.either(
(artist) => {
track.artist = artist;
if (track.album) {
track.album.artist = artist;
}
seenItems.set(artist.externalId, artist);
},
(err) => {
this.#logger.error(
err,
'Error querying Plex artist %s for track %s',
artistId,
track.externalId,
);
},
);
}
}
return track;
})
.exhaustive();
}
}

View File

@@ -8,6 +8,7 @@ import type { MediaLibraryType } from '../db/schema/MediaSource.ts';
import { KEYS } from '../types/inject.ts';
import { bindFactoryFunc } from '../util/inject.ts';
import type { Canonicalizer } from './Canonicalizer.ts';
import { CustomShowSyncService } from './CustomShowSyncService.ts';
import { EmbyItemCanonicalizer } from './EmbyItemCanonicalizer.ts';
import { JellyfinItemCanonicalizer } from './JellyfinItemCanonicalizer.ts';
import type { FolderAndContents } from './LocalFolderCanonicalizer.ts';
@@ -125,6 +126,10 @@ export const ServicesModule = new ContainerModule((bind) => {
.to(EmbyMediaSourceMusicVideoScanner)
.whenTargetNamed(MediaSourceType.Emby);
bind<CustomShowSyncService>(CustomShowSyncService)
.toSelf()
.inSingletonScope();
bind<GenericMediaSourceScannerFactory>(
KEYS.MediaSourceLibraryScanner,
).toFactory<

View File

@@ -13,7 +13,7 @@ import { map } from 'lodash-es';
import type { IChannelDB } from '../../db/interfaces/IChannelDB.js';
import type { IProgramDB } from '../../db/interfaces/IProgramDB.js';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { PlexItemEnumerator } from '../PlexItemEnumerator.js';
import { PlexHierarchyTraversal } from '../PlexItemEnumerator.js';
import { ContentSourceUpdater } from './ContentSourceUpdater.js';
export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicContentConfigPlexSource> {
@@ -69,13 +69,10 @@ export class PlexContentSourceUpdater extends ContentSourceUpdater<DynamicConten
),
);
const enumerator = new PlexItemEnumerator(this.#plex);
const enumerator = new PlexHierarchyTraversal(this.#plex);
const enumeratedItems = await this.#timer.timeAsync('enumerate items', () =>
enumerator.enumerateItems(
this.#mediaSource,
plexResult.getOrThrow().result,
),
enumerator.expandDescendants(plexResult.getOrThrow().result),
);
const channelPrograms: ContentProgram[] = seq.collect(

View File

@@ -9,6 +9,7 @@ import { ScanLibrariesTask } from '../../tasks/ScanLibrariesTask.ts';
import { ScheduledTask } from '../../tasks/ScheduledTask.ts';
import { ScheduleDynamicChannelsTask } from '../../tasks/ScheduleDynamicChannelsTask.ts';
import { SubtitleExtractorTask } from '../../tasks/SubtitleExtractorTask.ts';
import { SyncCustomShowsTask } from '../../tasks/SyncCustomShowsTask.ts';
import { UpdateXmlTvTask } from '../../tasks/UpdateXmlTvTask.ts';
import { autoFactoryKey, KEYS } from '../../types/inject.ts';
import { Logger, LoggerFactory } from '../../util/logging/LoggerFactory.ts';
@@ -132,6 +133,20 @@ export class ScheduleJobsStartupTask extends SimpleStartupTask {
),
);
GlobalScheduler.scheduleTask(
SyncCustomShowsTask.ID,
new ScheduledTask(
SyncCustomShowsTask,
hoursCrontab(
this.settingsDB.globalMediaSourceSettings().rescanIntervalHours,
),
container.get<interfaces.AutoFactory<SyncCustomShowsTask>>(
SyncCustomShowsTask.KEY,
),
undefined,
),
);
scheduleBackupJobs(this.settingsDB.backup);
forEach(

View File

@@ -0,0 +1,33 @@
import type { Tag } from '@tunarr/types';
import { inject, injectable } from 'inversify';
import { CustomShowSyncService } from '../services/CustomShowSyncService.ts';
import { KEYS } from '../types/inject.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
import type { TaskMetadata } from './Task.ts';
import { SimpleTask } from './Task.ts';
import { simpleTaskDef } from './TaskRegistry.ts';
@injectable()
@simpleTaskDef({
description: 'Syncs all custom shows linked to external playlists',
})
export class SyncCustomShowsTask extends SimpleTask {
static KEY = Symbol.for(SyncCustomShowsTask.name);
static ID = SyncCustomShowsTask.name;
public ID = SyncCustomShowsTask.ID as Tag<
typeof SyncCustomShowsTask.name,
TaskMetadata
>;
constructor(
@inject(KEYS.Logger) logger: Logger,
@inject(CustomShowSyncService)
private syncService: CustomShowSyncService,
) {
super(logger);
}
protected async runInternal(): Promise<void> {
await this.syncService.syncAll();
}
}

View File

@@ -35,6 +35,7 @@ import { RemoveDanglingProgramsFromSearchTask } from './RemoveDanglingProgramsFr
import { RollLogFileTask } from './RollLogFileTask.ts';
import { ScanLibrariesTask } from './ScanLibrariesTask.ts';
import { SubtitleExtractorTask } from './SubtitleExtractorTask.ts';
import { SyncCustomShowsTask } from './SyncCustomShowsTask.ts';
export type ReconcileProgramDurationsTaskFactory = (
request?: ReconcileProgramDurationsTaskRequest,
@@ -147,6 +148,11 @@ const TasksModule = new ContainerModule((bind) => {
);
bind<RefreshMediaSourceLibraryTask>(RefreshMediaSourceLibraryTask).toSelf();
bind(SyncCustomShowsTask).toSelf();
bind<interfaces.Factory<SyncCustomShowsTask>>(
SyncCustomShowsTask.KEY,
).toAutoFactory(SyncCustomShowsTask);
});
export { TasksModule };

View File

@@ -34,4 +34,8 @@ export class MutexMap {
getLockSync(id: string): Maybe<MutexInterface> {
return this.#keyedLocks[id];
}
isLocked(id: string) {
return this.#keyedLocks[id]?.isLocked() ?? false;
}
}

View File

@@ -9,7 +9,7 @@ export default defineConfig({
},
},
test: {
name: 'ffmpeg_integration',
name: '@tunarr/server#integration',
globals: true,
watch: false,
include: ['src/**/*.local.test.ts'],

View File

@@ -210,7 +210,7 @@ export function isEpisodeWithHierarchy(
export function isMusicTrackWithHierarchy(
f: TerminalProgram,
): f is MusicTrackWithHierarchy {
return f.type === 'track' && !!f.album && (!!f.album?.artist || !!f.artist);
return f.type === 'track' && !!f.album && !!(f.album?.artist ?? f.artist);
}
export function getChildItemType(typ: ProgramOrFolder['type']) {

View File

@@ -85,7 +85,10 @@ export const BatchLookupExternalProgrammingSchema = z.object({
export const CreateCustomShowRequestSchema = z.object({
name: z.string(),
programs: z.array(ContentProgramSchema),
programs: z.array(ContentProgramSchema).default([]),
syncMediaSourceId: z.string().nullable(),
syncMediaSourceType: z.enum(['plex']).nullable(),
syncExternalPlaylistId: z.string().nullable(),
});
export type CreateCustomShowRequest = z.infer<
@@ -93,7 +96,9 @@ export type CreateCustomShowRequest = z.infer<
>;
export const UpdateCustomShowRequestSchema =
CreateCustomShowRequestSchema.partial();
CreateCustomShowRequestSchema.partial().extend({
enableSync: z.boolean(),
});
export type UpdateCustomShowRequest = z.infer<
typeof UpdateCustomShowRequestSchema

View File

@@ -5,10 +5,17 @@ export const CustomShowProgrammingSchema = z.array(
z.discriminatedUnion('type', [ContentProgramSchema, CustomProgramSchema]),
);
export const CustomShowSyncMediaSourceTypeSchema = z.enum(['plex']);
export const CustomShowSchema = z.object({
id: z.string(),
name: z.string(),
contentCount: z.number(),
programs: z.array(CustomProgramSchema).optional(),
totalDuration: z.number().nonnegative(),
syncMediaSourceId: z.string().nullish(),
syncMediaSourceType: CustomShowSyncMediaSourceTypeSchema.nullish(),
syncExternalPlaylistId: z.string().nullish(),
lastSyncedAt: z.number().nullish(),
isSyncing: z.boolean().default(false),
});

View File

@@ -1,4 +1,5 @@
import { queryClient } from '@/queryClient';
import { isNonEmptyString } from '@/helpers/util.ts';
import { customShowQuery } from '@/hooks/useCustomShows.ts';
import useStore from '@/store';
import {
clearCurrentCustomShow,
@@ -9,21 +10,28 @@ import {
} from '@/store/customShowEditor/actions.ts';
import { removeCustomShowProgram } from '@/store/entityEditor/util';
import { type UICustomShowProgram } from '@/types';
import { Save, Tv, Undo } from '@mui/icons-material';
import { Refresh, Save, Sync, Tv, Undo } from '@mui/icons-material';
import {
Alert,
Autocomplete,
Box,
Button,
CircularProgress,
Divider,
FormControlLabel,
Paper,
Stack,
Switch,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { type CustomShow } from '@tunarr/types';
import { useEffect } from 'react';
import { type CustomShow, type Playlist } from '@tunarr/types';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useCallback, useEffect } from 'react';
import { Controller, type SubmitHandler, useForm } from 'react-hook-form';
import {
getApiCustomShowsByIdProgramsQueryKey,
@@ -32,14 +40,22 @@ import {
} from '../../generated/@tanstack/react-query.gen.ts';
import {
createCustomShow,
getApiMediaSources,
getApiPlexByMediaSourceIdPlaylists,
putApiCustomShowsById,
syncCustomShow,
} from '../../generated/sdk.gen.ts';
import ChannelLineupList from '../channel_config/ChannelLineupList.tsx';
import { CustomShowSortToolsMenu } from './CustomShowSortToolsMenu.tsx';
dayjs.extend(relativeTime);
type CustomShowForm = {
id?: string;
name: string;
syncEnabled: boolean;
syncMediaSourceId: string;
syncExternalPlaylistId: string;
};
type Props = {
@@ -53,48 +69,119 @@ export function EditCustomShowsForm({
customShowPrograms,
isNew,
}: Props) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const customShowProgrammingChanged = useStore(
(s) => s.customShowEditor.dirty.programs,
);
useQuery({
...customShowQuery(customShow.id),
enabled: !isNew,
refetchInterval: customShow.isSyncing ? 5_000 : undefined,
});
const isSynced = !!customShow.syncMediaSourceId;
const {
control,
reset,
handleSubmit,
getValues,
watch,
setValue,
formState: { isValid, isDirty },
} = useForm<CustomShowForm>({
defaultValues: {
name: customShow.name ?? '',
syncEnabled: isSynced,
syncMediaSourceId: customShow.syncMediaSourceId ?? '',
syncExternalPlaylistId: customShow.syncExternalPlaylistId ?? '',
},
});
const [syncEnabled, selectedMediaSourceId] = watch([
'syncEnabled',
'syncMediaSourceId',
]);
useEffect(() => {
reset({
name: customShow.name,
syncEnabled: !!customShow.syncMediaSourceId,
syncMediaSourceId: customShow.syncMediaSourceId ?? undefined,
syncExternalPlaylistId: customShow.syncExternalPlaylistId ?? undefined,
});
}, [customShow, reset]);
// Fetch media sources for the dropdown
const { data: mediaSources } = useQuery({
queryKey: ['settings', 'media-sources'],
queryFn: async () => {
const result = await getApiMediaSources({ throwOnError: true });
return result.data;
},
});
// Filter to only Plex sources for now
const plexSources = (mediaSources ?? []).filter((s) => s.type === 'plex');
// Fetch playlists for selected media source
const { data: playlists, isLoading: playlistsLoading } = useQuery({
queryKey: ['plex', selectedMediaSourceId, 'playlists'],
queryFn: async () => {
const result = await getApiPlexByMediaSourceIdPlaylists({
path: { mediaSourceId: selectedMediaSourceId },
throwOnError: true,
});
return result.data;
},
enabled: syncEnabled && !!selectedMediaSourceId,
});
const playlistItems: Playlist[] = playlists?.result ?? [];
const saveShowMutation = useMutation({
mutationKey: ['custom-shows', isNew ? 'new' : customShow.id],
mutationFn: async (
data: CustomShowForm & { programs: UICustomShowProgram[] },
) => {
const body = {
name: data.name,
programs: data.syncEnabled ? [] : data.programs,
enableSync: data.syncEnabled,
...(data.syncEnabled &&
isNonEmptyString(data.syncMediaSourceId) &&
isNonEmptyString(data.syncExternalPlaylistId)
? {
syncMediaSourceId: data.syncMediaSourceId,
syncMediaSourceType: 'plex' as const,
syncExternalPlaylistId: data.syncExternalPlaylistId,
}
: {
syncMediaSourceId: null,
syncMediaSourceType: null,
syncExternalPlaylistId: null,
}),
};
if (isNew) {
return createCustomShow({ body: data, throwOnError: true });
return createCustomShow({ body, throwOnError: true });
} else {
return putApiCustomShowsById({
path: {
id: customShow.id,
},
body: data,
path: { id: customShow.id },
body,
throwOnError: true,
});
}
},
onSuccess: async (updatedShow) => {
reset({ name: updatedShow.data.name });
reset({
name: updatedShow.data.name,
syncEnabled: !!updatedShow.data.syncMediaSourceId,
syncMediaSourceId: updatedShow.data.syncMediaSourceId ?? undefined,
syncExternalPlaylistId:
updatedShow.data.syncExternalPlaylistId ?? undefined,
});
await queryClient.invalidateQueries({
queryKey: getApiCustomShowsQueryKey(),
});
@@ -120,6 +207,29 @@ export function EditCustomShowsForm({
},
});
const syncNowMutation = useMutation({
mutationKey: ['custom-shows', customShow.id, 'sync'],
mutationFn: async () => {
return syncCustomShow({
path: { id: customShow.id },
throwOnError: true,
});
},
onSuccess: async (updatedShow) => {
updateCurrentCustomShow(updatedShow.data);
await queryClient.invalidateQueries({
queryKey: getApiCustomShowsByIdProgramsQueryKey({
path: { id: customShow.id },
}),
});
await queryClient.invalidateQueries({
queryKey: getApiCustomShowsByIdQueryKey({
path: { id: customShow.id },
}),
});
},
});
const saveCustomShow: SubmitHandler<CustomShowForm> = (
data: CustomShowForm,
) => {
@@ -138,6 +248,16 @@ export function EditCustomShowsForm({
}).catch(console.warn);
};
const handleSyncToggle = useCallback(
(checked: boolean) => {
if (!checked) {
setValue('syncMediaSourceId', '');
setValue('syncExternalPlaylistId', '');
}
},
[setValue],
);
return (
<Box component="form" onSubmit={handleSubmit(saveCustomShow)}>
<Stack gap={2}>
@@ -148,8 +268,135 @@ export function EditCustomShowsForm({
<TextField margin="normal" fullWidth label="Name" {...field} />
)}
/>
<Divider />
<Box>
<Controller
control={control}
name="syncEnabled"
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(e) => {
field.onChange(e.target.checked);
handleSyncToggle(e.target.checked);
}}
/>
}
label="Sync with external playlist"
/>
)}
/>
</Box>
{syncEnabled && (
<Stack gap={2}>
<Controller
control={control}
name="syncMediaSourceId"
rules={{
required: syncEnabled,
minLength: syncEnabled ? 1 : 0,
}}
render={({ field }) => (
<Autocomplete
options={plexSources}
getOptionLabel={(opt) => opt.name}
value={plexSources.find((s) => s.id === field.value) ?? null}
onChange={(_, newVal) => {
field.onChange(newVal?.id ?? undefined);
setValue('syncExternalPlaylistId', '');
}}
renderInput={(params) => (
<TextField {...params} label="Media Source" />
)}
/>
)}
/>
{selectedMediaSourceId && (
<Controller
control={control}
name="syncExternalPlaylistId"
rules={{
required: syncEnabled,
minLength: syncEnabled ? 1 : 0,
}}
render={({ field }) => (
<Autocomplete
options={playlistItems}
getOptionLabel={(opt) => opt.title}
value={
playlistItems.find((p) => p.externalId === field.value) ??
null
}
onChange={(_, newVal) =>
field.onChange(newVal?.externalId ?? undefined)
}
loading={playlistsLoading}
renderInput={(params) => (
<TextField
{...params}
label="Playlist"
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{playlistsLoading && (
<CircularProgress size={20} />
)}
{params.InputProps.endAdornment}
</>
),
},
}}
/>
)}
/>
)}
/>
)}
{!isNew && isSynced && (
<Stack direction="row" gap={2} sx={{ alignItems: 'center' }}>
<Button
variant="outlined"
startIcon={
syncNowMutation.isPending ? (
<CircularProgress size={16} />
) : (
<Refresh />
)
}
onClick={() => syncNowMutation.mutate()}
disabled={syncNowMutation.isPending || customShow.isSyncing}
>
Sync Now
</Button>
{customShow.lastSyncedAt && (
<Typography variant="body2" color="text.secondary">
Last synced {dayjs(customShow.lastSyncedAt).fromNow()}
</Typography>
)}
</Stack>
)}
</Stack>
)}
<Divider />
<Box>
{isSynced && !isNew && (
<Alert severity="info" sx={{ mb: 2 }} icon={<Sync />}>
This custom show is synced with an external playlist. Content is
updated automatically and cannot be edited manually.
</Alert>
)}
<Box>
<Stack
direction="row"
@@ -163,35 +410,42 @@ export function EditCustomShowsForm({
Programming
</Typography>
<CustomShowSortToolsMenu />
{customShowProgrammingChanged && (
<Tooltip title="Reset programming to most recently saved state">
<Button
variant="contained"
startIcon={<Undo />}
onClick={() => resetCustomShowProgramming()}
{!syncEnabled && (
<>
<CustomShowSortToolsMenu />
{customShowProgrammingChanged && (
<Tooltip title="Reset programming to most recently saved state">
<Button
variant="contained"
startIcon={<Undo />}
onClick={() => resetCustomShowProgramming()}
>
Reset
</Button>
</Tooltip>
)}
<Tooltip
title="Add programming to custom show"
placement="top"
>
Reset
</Button>
</Tooltip>
<Button
disableRipple
component="button"
onClick={() => navToProgramming()}
startIcon={<Tv />}
variant="contained"
>
Add Media
</Button>
</Tooltip>
</>
)}
<Tooltip title="Add programming to custom show" placement="top">
<Button
disableRipple
component="button"
onClick={() => navToProgramming()}
startIcon={<Tv />}
variant="contained"
>
Add Media
</Button>
</Tooltip>
<Button
disabled={
saveShowMutation.isPending ||
!isValid ||
(!isDirty && !customShowProgrammingChanged) ||
customShowPrograms.length === 0
(!syncEnabled && customShowPrograms.length === 0)
}
variant="contained"
type="submit"
@@ -207,10 +461,13 @@ export function EditCustomShowsForm({
programListSelector={(s) => s.customShowEditor.programList}
moveProgram={moveProgramInCustomShow}
deleteProgram={removeCustomShowProgram}
enableDnd={!syncEnabled}
enableRowDelete={!syncEnabled}
enableRowEdit={!syncEnabled}
virtualListProps={{
width: '100%',
height: 600,
itemSize: 35, //smallViewport ? 70 : 35,
itemSize: 35,
}}
/>
</Paper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5593,6 +5593,11 @@ export type GetApiChannelsByIdScheduleResponses = {
id: string;
name: string;
contentCount: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
} | null;
isMissing: boolean;
} | {
@@ -5849,6 +5854,11 @@ export type GetApiChannelsByIdScheduleResponses = {
id: string;
name: string;
contentCount: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
} | null;
isMissing: boolean;
} | {
@@ -5907,6 +5917,80 @@ export type GetApiChannelsByIdScheduleResponses = {
export type GetApiChannelsByIdScheduleResponse = GetApiChannelsByIdScheduleResponses[keyof GetApiChannelsByIdScheduleResponses];
export type GetApiChannelsByIdNativePlaybackData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/api/channels/{id}/native-playback';
};
export type GetApiChannelsByIdNativePlaybackErrors = {
/**
* Default Response
*/
404: unknown;
};
export type GetApiChannelsByIdNativePlaybackResponses = {
/**
* Default Response
*/
200: {
channelId: string;
channelNumber: number;
channelName: string;
serverTimeMs: number;
current: {
kind: 'content';
itemStartedAtMs: number;
seekOffsetMs: number;
remainingMs: number;
programId: string;
title: string;
episodeTitle?: string;
seasonNumber?: number;
episodeNumber?: number;
summary?: string;
thumb?: string;
streamUrl: string;
} | {
kind: 'flex';
remainingMs: number;
itemStartedAtMs: number;
} | {
kind: 'error';
message: string;
retryAfterMs: number;
};
next?: {
kind: 'content';
itemStartedAtMs: number;
seekOffsetMs: number;
remainingMs: number;
programId: string;
title: string;
episodeTitle?: string;
seasonNumber?: number;
episodeNumber?: number;
summary?: string;
thumb?: string;
streamUrl: string;
} | {
kind: 'flex';
remainingMs: number;
itemStartedAtMs: number;
} | {
kind: 'error';
message: string;
retryAfterMs: number;
};
};
};
export type GetApiChannelsByIdNativePlaybackResponse = GetApiChannelsByIdNativePlaybackResponses[keyof GetApiChannelsByIdNativePlaybackResponses];
export type GetApiCustomShowsData = {
body?: never;
path?: never;
@@ -5942,6 +6026,11 @@ export type GetApiCustomShowsResponses = {
};
}>;
totalDuration: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
}>;
};
@@ -5950,7 +6039,7 @@ export type GetApiCustomShowsResponse = GetApiCustomShowsResponses[keyof GetApiC
export type CreateCustomShowData = {
body: {
name: string;
programs: Array<{
programs?: Array<{
type: 'content';
persisted: boolean;
duration: number;
@@ -5960,6 +6049,9 @@ export type CreateCustomShowData = {
startOffsetMs?: number;
program: TerminalProgramInput;
}>;
syncMediaSourceId: string | null;
syncMediaSourceType: 'plex' | null;
syncExternalPlaylistId: string | null;
};
path?: never;
query?: never;
@@ -5994,6 +6086,11 @@ export type CreateCustomShowResponses = {
};
}>;
totalDuration: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
};
};
@@ -6070,13 +6167,18 @@ export type GetApiCustomShowsByIdResponses = {
};
}>;
totalDuration: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
};
};
export type GetApiCustomShowsByIdResponse = GetApiCustomShowsByIdResponses[keyof GetApiCustomShowsByIdResponses];
export type PutApiCustomShowsByIdData = {
body?: {
body: {
name?: string;
programs?: Array<{
type: 'content';
@@ -6088,6 +6190,10 @@ export type PutApiCustomShowsByIdData = {
startOffsetMs?: number;
program: TerminalProgramInput;
}>;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
enableSync: boolean;
};
path: {
id: string;
@@ -6131,6 +6237,11 @@ export type PutApiCustomShowsByIdResponses = {
};
}>;
totalDuration: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
};
};
@@ -6179,6 +6290,68 @@ export type GetApiCustomShowsByIdProgramsResponses = {
export type GetApiCustomShowsByIdProgramsResponse = GetApiCustomShowsByIdProgramsResponses[keyof GetApiCustomShowsByIdProgramsResponses];
export type SyncCustomShowData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/api/custom-shows/{id}/sync';
};
export type SyncCustomShowErrors = {
/**
* Default Response
*/
400: {
error: string;
};
/**
* Default Response
*/
404: unknown;
};
export type SyncCustomShowError = SyncCustomShowErrors[keyof SyncCustomShowErrors];
export type SyncCustomShowResponses = {
/**
* Default Response
*/
200: {
id: string;
name: string;
contentCount: number;
programs?: Array<{
type: 'custom';
persisted: boolean;
duration: number;
icon?: string;
id: string;
customShowId: string;
index: number;
program?: {
type: 'content';
persisted: boolean;
duration: number;
icon?: string;
id?: string;
uniqueId: string;
startOffsetMs?: number;
program: TerminalProgram;
};
}>;
totalDuration: number;
syncMediaSourceId?: string | null;
syncMediaSourceType?: 'plex' | null;
syncExternalPlaylistId?: string | null;
lastSyncedAt?: number | null;
isSyncing: boolean;
};
};
export type SyncCustomShowResponse = SyncCustomShowResponses[keyof SyncCustomShowResponses];
export type GetApiFillerListsData = {
body?: never;
path?: never;
@@ -12088,6 +12261,24 @@ export type HeadStreamChannelsByIdM3U8Responses = {
200: unknown;
};
export type GetStreamChannelsByIdItemStreamTsData = {
body?: never;
path: {
id: string;
};
query: {
t: number;
};
url: '/stream/channels/{id}/item-stream.ts';
};
export type GetStreamChannelsByIdItemStreamTsResponses = {
/**
* Default Response
*/
200: unknown;
};
export type ClientOptions = {
baseURL: string;
};

View File

@@ -1,4 +1,4 @@
import { Delete, Edit } from '@mui/icons-material';
import { Delete, Edit, Sync } from '@mui/icons-material';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import {
Dialog,
@@ -140,6 +140,16 @@ export default function CustomShowsPage() {
{
header: 'Name',
accessorKey: 'name',
Cell: ({ row }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{row.original.name}
{row.original.syncMediaSourceId && (
<Tooltip title="Synced with external playlist">
<Sync fontSize="small" color="primary" />
</Tooltip>
)}
</Box>
),
},
{
header: '# Programs',

View File

@@ -11,6 +11,7 @@ export const Route = createFileRoute('/library/custom-shows_/new/')({
name: '',
contentCount: 0,
totalDuration: 0,
isSyncing: false,
};
const existingNewFiller =