mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: ability to sync custom shows with upstream source
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
server/src/external/plex/PlexApiClient.ts
vendored
36
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,9 @@ export class DirectMigrationProvider implements MigrationProvider {
|
||||
migration1773603770: makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0042_supreme_medusa.sql',
|
||||
),
|
||||
migration1775060606: makeKyselyMigrationFromSqlFile(
|
||||
'./sql/0043_common_zzzax.sql',
|
||||
),
|
||||
},
|
||||
wrapWithTransaction,
|
||||
),
|
||||
|
||||
29
server/src/migration/db/sql/0043_common_zzzax.sql
Normal file
29
server/src/migration/db/sql/0043_common_zzzax.sql
Normal 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;
|
||||
4050
server/src/migration/db/sql/meta/0043_snapshot.json
Normal file
4050
server/src/migration/db/sql/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,13 @@
|
||||
"when": 1773603770514,
|
||||
"tag": "0042_supreme_medusa",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1775060587475,
|
||||
"tag": "0043_common_zzzax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
144
server/src/services/CustomShowSyncService.ts
Normal file
144
server/src/services/CustomShowSyncService.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
33
server/src/tasks/SyncCustomShowsTask.ts
Normal file
33
server/src/tasks/SyncCustomShowsTask.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: 'ffmpeg_integration',
|
||||
name: '@tunarr/server#integration',
|
||||
globals: true,
|
||||
watch: false,
|
||||
include: ['src/**/*.local.test.ts'],
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const Route = createFileRoute('/library/custom-shows_/new/')({
|
||||
name: '',
|
||||
contentCount: 0,
|
||||
totalDuration: 0,
|
||||
isSyncing: false,
|
||||
};
|
||||
|
||||
const existingNewFiller =
|
||||
|
||||
Reference in New Issue
Block a user