mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: experimental auto channel creation
This commit is contained in:
82
server/src/api/autoChannelApi.ts
Normal file
82
server/src/api/autoChannelApi.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
AutoChannelCreateRequestSchema,
|
||||
ChannelPresetSchema,
|
||||
ContentPreviewResponseSchema,
|
||||
ContentQuerySchema,
|
||||
} from '@tunarr/types/api';
|
||||
import { ChannelSchema } from '@tunarr/types/schemas';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { z } from 'zod/v4';
|
||||
import { AutoChannelService } from '../services/AutoChannelService.ts';
|
||||
import type { RouterPluginAsyncCallback } from '../types/serverType.js';
|
||||
import type { ApiController } from './ApiController.ts';
|
||||
|
||||
@injectable()
|
||||
export class AutoChannelApiController implements ApiController {
|
||||
constructor(
|
||||
@inject(AutoChannelService) private autoChannelService: AutoChannelService,
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
mount: RouterPluginAsyncCallback = async (fastify) => {
|
||||
fastify.get(
|
||||
'/auto-channels/presets',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Auto Channels'],
|
||||
description: 'List available channel presets',
|
||||
response: {
|
||||
200: ChannelPresetSchema.array(),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_req, res) => {
|
||||
const presets = this.autoChannelService.getPresets();
|
||||
return res.send(presets);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/auto-channels/preview-content',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Auto Channels'],
|
||||
description: 'Preview content matching a query',
|
||||
body: ContentQuerySchema,
|
||||
response: {
|
||||
200: ContentPreviewResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
const preview = await this.autoChannelService.previewContent(req.body);
|
||||
return res.send(preview);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/auto-channels/create',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Auto Channels'],
|
||||
description: 'Create a channel from a preset',
|
||||
body: AutoChannelCreateRequestSchema,
|
||||
response: {
|
||||
201: ChannelSchema,
|
||||
400: z.object({ message: z.string() }),
|
||||
500: z.object({ message: z.string() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (req, res) => {
|
||||
try {
|
||||
const channel = await this.autoChannelService.createChannel(req.body);
|
||||
return res.status(201).send(channel);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { z } from 'zod/v4';
|
||||
import { container } from '../container.ts';
|
||||
import { TruthyQueryParam } from '../types/schemas.ts';
|
||||
import { isNonEmptyString, run } from '../util/index.js';
|
||||
import { AutoChannelApiController } from './autoChannelApi.ts';
|
||||
import { channelsApi } from './channelsApi.js';
|
||||
import { nativePlaybackApi } from './nativePlaybackApi.js';
|
||||
import { CreditsApiController } from './creditsApi.ts';
|
||||
@@ -83,7 +84,8 @@ export const apiRouter: RouterPluginAsyncCallback = async (fastify) => {
|
||||
.register(trashApi)
|
||||
.register(container.get(SmartCollectionsApiController).mount)
|
||||
.register(container.get(CreditsApiController).mount)
|
||||
.register(container.get(ProgramGroupingApiController).mount);
|
||||
.register(container.get(ProgramGroupingApiController).mount)
|
||||
.register(container.get(AutoChannelApiController).mount);
|
||||
|
||||
fastify.get(
|
||||
'/version',
|
||||
|
||||
348
server/src/services/AutoChannelService.ts
Normal file
348
server/src/services/AutoChannelService.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
BuiltInPresets,
|
||||
getPresetById,
|
||||
} from '@tunarr/shared/channel-templates';
|
||||
import { isNonEmptyString, search } from '@tunarr/shared/util';
|
||||
import type { Channel, SaveableChannel } from '@tunarr/types';
|
||||
import type {
|
||||
AutoChannelCreateRequest,
|
||||
ChannelPreset,
|
||||
ContentPreviewResponse,
|
||||
ContentQuery,
|
||||
UpdateChannelProgrammingRequest,
|
||||
} from '@tunarr/types/api';
|
||||
import type { SearchFilter } from '@tunarr/types/schemas';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { countBy, sumBy, take } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
import { ChannelDB } from '../db/ChannelDB.ts';
|
||||
import { dbChannelToApiChannel } from '../db/converters/channelConverters.ts';
|
||||
import { SmartCollectionsDB } from '../db/SmartCollectionsDB.ts';
|
||||
import { UpdateXmlTvTask } from '../tasks/UpdateXmlTvTask.js';
|
||||
import { KEYS } from '../types/inject.ts';
|
||||
import type { Logger } from '../util/logging/LoggerFactory.ts';
|
||||
import type {
|
||||
ProgramSearchDocument,
|
||||
TerminalProgramSearchDocument,
|
||||
} from './MeilisearchService.ts';
|
||||
import { MeilisearchService } from './MeilisearchService.ts';
|
||||
import { GlobalScheduler } from './Scheduler.js';
|
||||
|
||||
@injectable()
|
||||
export class AutoChannelService {
|
||||
constructor(
|
||||
@inject(KEYS.Logger) private logger: Logger,
|
||||
@inject(MeilisearchService) private searchService: MeilisearchService,
|
||||
@inject(SmartCollectionsDB) private smartCollectionsDB: SmartCollectionsDB,
|
||||
@inject(ChannelDB) private channelDB: ChannelDB,
|
||||
) {}
|
||||
|
||||
getPresets(): ChannelPreset[] {
|
||||
return BuiltInPresets;
|
||||
}
|
||||
|
||||
async previewContent(query: ContentQuery): Promise<ContentPreviewResponse> {
|
||||
const results = await this.resolveContentQuery(query);
|
||||
|
||||
// Filter to terminal programs only (programs with duration, not groupings)
|
||||
const terminalResults = results.filter(
|
||||
(r): r is TerminalProgramSearchDocument =>
|
||||
r.type === 'movie' ||
|
||||
r.type === 'episode' ||
|
||||
r.type === 'track' ||
|
||||
r.type === 'music_video' ||
|
||||
r.type === 'other_video',
|
||||
);
|
||||
|
||||
const byType = countBy(terminalResults, 'type') as Record<string, number>;
|
||||
|
||||
// Collect top shows from episodes
|
||||
const showCounts: Record<string, number> = {};
|
||||
for (const result of terminalResults) {
|
||||
if (result.type === 'episode' && result.grandparent?.title) {
|
||||
const showName = result.grandparent.title;
|
||||
showCounts[showName] = (showCounts[showName] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const topShows = Object.entries(showCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10)
|
||||
.map(([name, episodeCount]) => ({ name, episodeCount }));
|
||||
|
||||
const totalDurationMs = sumBy(terminalResults, 'duration');
|
||||
|
||||
return {
|
||||
totalPrograms: terminalResults.length,
|
||||
byType,
|
||||
topShows,
|
||||
totalDurationMs,
|
||||
sampleIds: take(terminalResults, 20).map((r) => r.id),
|
||||
};
|
||||
}
|
||||
|
||||
async createChannel(request: AutoChannelCreateRequest): Promise<Channel> {
|
||||
const preset = getPresetById(request.presetId);
|
||||
if (!preset) {
|
||||
throw new Error(`Preset not found: ${request.presetId}`);
|
||||
}
|
||||
|
||||
// Step 1: Resolve content for each role and create smart collections
|
||||
const smartCollectionIdsByRole: Record<string, string> = {};
|
||||
|
||||
for (const requirement of preset.contentRequirements) {
|
||||
const assignment = request.contentAssignments[requirement.role];
|
||||
const query = assignment?.query ?? requirement.defaultQuery;
|
||||
|
||||
// Create a smart collection for this role
|
||||
const collectionName = `${request.channelName ?? preset.name} - ${requirement.label}`;
|
||||
const filterString = this.contentQueryToFilterString(query);
|
||||
|
||||
const collectionResult = await this.smartCollectionsDB.insert({
|
||||
name: collectionName,
|
||||
keywords: query.keywords ?? '',
|
||||
filter: filterString ? this.parseFilterString(filterString) : undefined,
|
||||
filterString,
|
||||
});
|
||||
|
||||
if (collectionResult.isFailure()) {
|
||||
throw new Error(
|
||||
`Failed to create smart collection for role ${requirement.role}: ${collectionResult.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
smartCollectionIdsByRole[requirement.role] = collectionResult.get().uuid;
|
||||
}
|
||||
|
||||
// Step 2: Build the schedule config with resolved smart collection IDs
|
||||
const scheduleConfig = structuredClone(preset.scheduleConfig);
|
||||
|
||||
// Replace placeholder smart collection IDs in slots
|
||||
for (const slot of 'slots' in scheduleConfig ? scheduleConfig.slots : []) {
|
||||
if (slot.type === 'smart-collection' && !slot.smartCollectionId) {
|
||||
// Assign the first role's smart collection as default
|
||||
const firstRole = preset.contentRequirements[0];
|
||||
if (firstRole) {
|
||||
slot.smartCollectionId =
|
||||
smartCollectionIdsByRole[firstRole.role] ?? '';
|
||||
}
|
||||
} else if (
|
||||
slot.type === 'smart-collection' &&
|
||||
slot.smartCollectionId in smartCollectionIdsByRole
|
||||
) {
|
||||
slot.smartCollectionId =
|
||||
smartCollectionIdsByRole[slot.smartCollectionId]!;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Resolve program IDs for the schedule
|
||||
const allProgramIds: string[] = [];
|
||||
for (const requirement of preset.contentRequirements) {
|
||||
const assignment = request.contentAssignments[requirement.role];
|
||||
|
||||
if (assignment?.programIds?.length) {
|
||||
allProgramIds.push(...assignment.programIds);
|
||||
} else {
|
||||
const collectionId = smartCollectionIdsByRole[requirement.role];
|
||||
if (collectionId) {
|
||||
const programs =
|
||||
await this.smartCollectionsDB.materializeSmartCollection(
|
||||
collectionId,
|
||||
true,
|
||||
);
|
||||
allProgramIds.push(...programs.map((p) => p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Determine the next available channel number
|
||||
const channelNumber =
|
||||
request.channelNumber ?? (await this.getNextChannelNumber());
|
||||
const channelName =
|
||||
request.channelName ?? `${preset.name} ${channelNumber}`;
|
||||
|
||||
// Step 5: Create the channel
|
||||
const channelData: SaveableChannel = {
|
||||
id: v4(),
|
||||
name: channelName,
|
||||
number: channelNumber,
|
||||
duration: 0,
|
||||
startTime: Date.now(),
|
||||
groupTitle: 'Auto-Created',
|
||||
icon: {
|
||||
path: '',
|
||||
width: 0,
|
||||
duration: 0,
|
||||
position: 'bottom-right',
|
||||
},
|
||||
stealth: false,
|
||||
disableFillerOverlay: false,
|
||||
guideMinimumDuration: 30000,
|
||||
offline: {
|
||||
mode: 'pic',
|
||||
picture: undefined,
|
||||
soundtrack: undefined,
|
||||
},
|
||||
streamMode: 'hls',
|
||||
transcodeConfigId: 'default',
|
||||
subtitlesEnabled: false,
|
||||
};
|
||||
|
||||
const channelAndLineup = await this.channelDB.saveChannel(channelData);
|
||||
|
||||
// Step 6: Set the lineup using the schedule
|
||||
const channelId = channelAndLineup.channel.uuid;
|
||||
const seed = request.seed ? [request.seed] : undefined;
|
||||
|
||||
let lineupRequest: UpdateChannelProgrammingRequest;
|
||||
if (scheduleConfig.type === 'time') {
|
||||
lineupRequest = {
|
||||
type: 'time',
|
||||
programs: allProgramIds,
|
||||
schedule: scheduleConfig,
|
||||
seed,
|
||||
};
|
||||
} else {
|
||||
lineupRequest = {
|
||||
type: 'random',
|
||||
programs: allProgramIds,
|
||||
schedule: scheduleConfig,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
await this.channelDB.updateLineup(channelId, lineupRequest);
|
||||
|
||||
// Step 7: Trigger guide regeneration
|
||||
try {
|
||||
GlobalScheduler.getScheduledJob(UpdateXmlTvTask.ID)
|
||||
.runNow(true)
|
||||
.catch((err) =>
|
||||
this.logger.error(err, 'Error regenerating guide after auto-create'),
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
e,
|
||||
'Unable to trigger guide update after auto-channel creation',
|
||||
);
|
||||
}
|
||||
|
||||
// Reload and return the created channel with relations
|
||||
const createdChannel = await this.channelDB.getChannel(channelId);
|
||||
if (!createdChannel) {
|
||||
throw new Error('Channel was created but could not be retrieved');
|
||||
}
|
||||
|
||||
const lineup = await this.channelDB.loadLineup(channelId);
|
||||
|
||||
return dbChannelToApiChannel({
|
||||
channel: createdChannel,
|
||||
lineup,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveContentQuery(
|
||||
query: ContentQuery,
|
||||
): Promise<ProgramSearchDocument[]> {
|
||||
let searchFilter: SearchFilter | undefined;
|
||||
|
||||
if (isNonEmptyString(query.filterString)) {
|
||||
searchFilter = this.parseFilterString(query.filterString);
|
||||
}
|
||||
|
||||
// Build type filter if programTypes specified
|
||||
if (query.programTypes?.length) {
|
||||
const typeFilter: SearchFilter = {
|
||||
type: 'value',
|
||||
fieldSpec: {
|
||||
key: 'type',
|
||||
name: 'Type',
|
||||
op: 'in',
|
||||
type: 'string',
|
||||
value: query.programTypes,
|
||||
},
|
||||
};
|
||||
|
||||
if (searchFilter) {
|
||||
searchFilter = {
|
||||
type: 'op',
|
||||
op: 'and',
|
||||
children: [searchFilter, typeFilter],
|
||||
};
|
||||
} else {
|
||||
searchFilter = typeFilter;
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate through all results
|
||||
const results: ProgramSearchDocument[] = [];
|
||||
let page = query.keywords ? 1 : 0;
|
||||
|
||||
for (;;) {
|
||||
const pageResult = await this.searchService.search('programs', {
|
||||
paging: { page, limit: 100 },
|
||||
query: query.keywords ?? null,
|
||||
filter: searchFilter ?? null,
|
||||
libraryId: query.libraryIds?.[0],
|
||||
mediaSourceId: query.mediaSourceIds?.[0],
|
||||
});
|
||||
|
||||
if (pageResult.results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
results.push(...pageResult.results);
|
||||
page++;
|
||||
|
||||
// Safety limit to prevent runaway queries
|
||||
if (results.length >= 10000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private contentQueryToFilterString(query: ContentQuery): string | undefined {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (isNonEmptyString(query.filterString)) {
|
||||
parts.push(query.filterString);
|
||||
}
|
||||
|
||||
if (query.programTypes?.length) {
|
||||
const types = query.programTypes.map((t) => `"${t}"`).join(', ');
|
||||
parts.push(`type in (${types})`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' AND ') : undefined;
|
||||
}
|
||||
|
||||
private parseFilterString(filterString: string): SearchFilter | undefined {
|
||||
const tokenized = search.tokenizeSearchQuery(filterString);
|
||||
if (tokenized.errors.length > 0) {
|
||||
this.logger.warn('Could not tokenize filter string: %s', filterString);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use the search parser from shared
|
||||
const parser = new search.SearchParser();
|
||||
parser.input = tokenized.tokens;
|
||||
const clause = parser.searchExpression();
|
||||
if (parser.errors.length > 0) {
|
||||
this.logger.warn('Could not parse filter string: %s', filterString);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return search.parsedSearchToRequest(clause);
|
||||
}
|
||||
|
||||
private async getNextChannelNumber(): Promise<number> {
|
||||
const channels = await this.channelDB.getAllChannels();
|
||||
const usedNumbers = new Set(channels.map((c) => c.number));
|
||||
let next = 1;
|
||||
while (usedNumbers.has(next)) {
|
||||
next++;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/src/types/index.d.ts"
|
||||
},
|
||||
"./channel-templates": {
|
||||
"types": "./dist/src/channel-templates/presets.d.ts",
|
||||
"default": "./dist/src/channel-templates/presets.js"
|
||||
}
|
||||
},
|
||||
"main": "index.ts",
|
||||
|
||||
96
shared/src/channel-templates/presets.ts
Normal file
96
shared/src/channel-templates/presets.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ChannelPreset } from '@tunarr/types/api';
|
||||
|
||||
export const ShufflePreset: ChannelPreset = {
|
||||
id: '24-7-shuffle',
|
||||
name: '24/7 Shuffle',
|
||||
description:
|
||||
'Shuffles all matching content continuously. The simplest channel type.',
|
||||
category: 'simple',
|
||||
contentRequirements: [
|
||||
{
|
||||
role: 'all',
|
||||
label: 'All Content',
|
||||
description: 'All content that will be shuffled on this channel.',
|
||||
defaultQuery: {},
|
||||
required: true,
|
||||
minPrograms: 1,
|
||||
},
|
||||
],
|
||||
scheduleType: 'random',
|
||||
scheduleConfig: {
|
||||
type: 'random',
|
||||
flexPreference: 'end',
|
||||
maxDays: 30,
|
||||
padMs: 0,
|
||||
padStyle: 'episode',
|
||||
slots: [
|
||||
{
|
||||
type: 'smart-collection',
|
||||
smartCollectionId: '', // Resolved at creation time
|
||||
order: 'shuffle',
|
||||
direction: 'asc',
|
||||
cooldownMs: 0,
|
||||
weight: 1,
|
||||
durationSpec: {
|
||||
type: 'dynamic',
|
||||
programCount: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
randomDistribution: 'uniform',
|
||||
lockWeights: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MovieChannelPreset: ChannelPreset = {
|
||||
id: 'movie-channel',
|
||||
name: 'Movie Channel',
|
||||
description:
|
||||
'A channel that plays movies continuously with optional fillers between them.',
|
||||
category: 'movie',
|
||||
contentRequirements: [
|
||||
{
|
||||
role: 'movies',
|
||||
label: 'Movies',
|
||||
description: 'Movies to play on this channel.',
|
||||
defaultQuery: {
|
||||
programTypes: ['movie'],
|
||||
},
|
||||
required: true,
|
||||
minPrograms: 1,
|
||||
},
|
||||
],
|
||||
scheduleType: 'random',
|
||||
scheduleConfig: {
|
||||
type: 'random',
|
||||
flexPreference: 'end',
|
||||
maxDays: 30,
|
||||
padMs: 30 * 60 * 1000, // 30-min pad alignment
|
||||
padStyle: 'slot',
|
||||
slots: [
|
||||
{
|
||||
type: 'smart-collection',
|
||||
smartCollectionId: '', // Resolved at creation time
|
||||
order: 'shuffle',
|
||||
direction: 'asc',
|
||||
cooldownMs: 0,
|
||||
weight: 1,
|
||||
durationSpec: {
|
||||
type: 'dynamic',
|
||||
programCount: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
randomDistribution: 'uniform',
|
||||
lockWeights: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const BuiltInPresets: ChannelPreset[] = [
|
||||
ShufflePreset,
|
||||
MovieChannelPreset,
|
||||
];
|
||||
|
||||
export function getPresetById(id: string): ChannelPreset | undefined {
|
||||
return BuiltInPresets.find((p) => p.id === id);
|
||||
}
|
||||
144
types/src/api/AutoChannel.ts
Normal file
144
types/src/api/AutoChannel.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { z } from 'zod/v4';
|
||||
import { ContentProgramTypeSchema } from '../schemas/utilSchemas.js';
|
||||
import { RandomSlotScheduleSchema } from './RandomSlots.js';
|
||||
import { TimeSlotScheduleSchema } from './TimeSlots.js';
|
||||
|
||||
//
|
||||
// Content query specification
|
||||
//
|
||||
export const ContentQuerySchema = z.object({
|
||||
filterString: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Existing search DSL filter (e.g., \'genre = "Action" AND year > 1980\')',
|
||||
),
|
||||
keywords: z.string().optional().describe('Free-text search via Meilisearch'),
|
||||
libraryIds: z
|
||||
.string()
|
||||
.array()
|
||||
.optional()
|
||||
.describe('Restrict to specific libraries'),
|
||||
mediaSourceIds: z
|
||||
.string()
|
||||
.array()
|
||||
.optional()
|
||||
.describe('Restrict to specific media sources'),
|
||||
programTypes: ContentProgramTypeSchema.array()
|
||||
.optional()
|
||||
.describe('Restrict to specific program types'),
|
||||
});
|
||||
|
||||
export type ContentQuery = z.infer<typeof ContentQuerySchema>;
|
||||
|
||||
//
|
||||
// Content preview response
|
||||
//
|
||||
export const ContentPreviewResponseSchema = z.object({
|
||||
totalPrograms: z.number(),
|
||||
byType: z.record(ContentProgramTypeSchema, z.number()),
|
||||
topShows: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
episodeCount: z.number(),
|
||||
}),
|
||||
),
|
||||
totalDurationMs: z.number(),
|
||||
sampleIds: z.string().array().describe('First ~20 program IDs for display'),
|
||||
});
|
||||
|
||||
export type ContentPreviewResponse = z.infer<
|
||||
typeof ContentPreviewResponseSchema
|
||||
>;
|
||||
|
||||
//
|
||||
// Content requirement (a role within a preset)
|
||||
//
|
||||
export const ContentRequirementSchema = z.object({
|
||||
role: z
|
||||
.string()
|
||||
.describe('Unique ID within preset (e.g., "primetime_movies")'),
|
||||
label: z.string().describe('Human-readable name'),
|
||||
description: z.string().optional(),
|
||||
defaultQuery: ContentQuerySchema.describe(
|
||||
'Pre-filled query, user can override',
|
||||
),
|
||||
required: z.boolean(),
|
||||
minPrograms: z.number().min(0),
|
||||
});
|
||||
|
||||
export type ContentRequirement = z.infer<typeof ContentRequirementSchema>;
|
||||
|
||||
//
|
||||
// Schedule skeleton
|
||||
//
|
||||
export const ScheduleSkeletonSchema = z.discriminatedUnion('type', [
|
||||
TimeSlotScheduleSchema,
|
||||
RandomSlotScheduleSchema,
|
||||
]);
|
||||
|
||||
export type ScheduleSkeleton = z.infer<typeof ScheduleSkeletonSchema>;
|
||||
|
||||
//
|
||||
// Channel preset
|
||||
//
|
||||
export const ChannelPresetCategorySchema = z.enum([
|
||||
'simple',
|
||||
'classic-tv',
|
||||
'movie',
|
||||
'music',
|
||||
'custom',
|
||||
]);
|
||||
|
||||
export type ChannelPresetCategory = z.infer<typeof ChannelPresetCategorySchema>;
|
||||
|
||||
export const ChannelPresetSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
category: ChannelPresetCategorySchema,
|
||||
contentRequirements: ContentRequirementSchema.array(),
|
||||
scheduleType: z.enum(['time', 'random']),
|
||||
scheduleConfig: ScheduleSkeletonSchema,
|
||||
});
|
||||
|
||||
export type ChannelPreset = z.infer<typeof ChannelPresetSchema>;
|
||||
|
||||
//
|
||||
// Content assignment for a role
|
||||
//
|
||||
export const ContentAssignmentSchema = z.object({
|
||||
query: ContentQuerySchema.optional().describe(
|
||||
'Query-based: system finds matching content',
|
||||
),
|
||||
programIds: z
|
||||
.string()
|
||||
.array()
|
||||
.optional()
|
||||
.describe('Explicit: user picked specific programs'),
|
||||
});
|
||||
|
||||
export type ContentAssignment = z.infer<typeof ContentAssignmentSchema>;
|
||||
|
||||
//
|
||||
// Auto-channel create request
|
||||
//
|
||||
export const AutoChannelCreateRequestSchema = z.object({
|
||||
presetId: z.string().describe('Built-in preset ID'),
|
||||
contentAssignments: z
|
||||
.record(z.string(), ContentAssignmentSchema)
|
||||
.describe('Map of role ID to content assignment'),
|
||||
channelName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Override the auto-generated name'),
|
||||
channelNumber: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Override the auto-assigned channel number'),
|
||||
seed: z.number().optional().describe('For reproducible generation'),
|
||||
});
|
||||
|
||||
export type AutoChannelCreateRequest = z.infer<
|
||||
typeof AutoChannelCreateRequestSchema
|
||||
>;
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { MaterializedSlot, RandomSlotScheduleSchema } from './RandomSlots.js';
|
||||
import { MaterializedTimeSlot, TimeSlotScheduleSchema } from './TimeSlots.js';
|
||||
|
||||
export * from './AutoChannel.js';
|
||||
export * from './CommonSlots.js';
|
||||
export * from './RandomSlots.js';
|
||||
export * from './Scheduling.js';
|
||||
|
||||
110
web/src/components/auto-channel/ContentPreview.tsx
Normal file
110
web/src/components/auto-channel/ContentPreview.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { ContentPreviewResponse } from '@tunarr/types/api';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
type ContentPreviewProps = {
|
||||
preview: ContentPreviewResponse | undefined;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const dur = dayjs.duration(ms);
|
||||
const days = Math.floor(dur.asDays());
|
||||
const hours = dur.hours();
|
||||
const minutes = dur.minutes();
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
return parts.join(' ') || '0m';
|
||||
}
|
||||
|
||||
export function ContentPreview({
|
||||
preview,
|
||||
isLoading,
|
||||
error,
|
||||
}: ContentPreviewProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Failed to preview content: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preview.totalPrograms === 0) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
No content found matching the query. Try broadening your search or
|
||||
checking your media sources.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={`${preview.totalPrograms} programs`}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`Total: ${formatDuration(preview.totalDurationMs)}`}
|
||||
variant="outlined"
|
||||
/>
|
||||
{Object.entries(preview.byType).map(([type, count]) => (
|
||||
<Chip
|
||||
key={type}
|
||||
label={`${count} ${type}${count !== 1 ? 's' : ''}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{preview.topShows.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Top Shows
|
||||
</Typography>
|
||||
<List dense disablePadding>
|
||||
{preview.topShows.slice(0, 5).map((show) => (
|
||||
<ListItem key={show.name} disableGutters>
|
||||
<ListItemText
|
||||
primary={show.name}
|
||||
secondary={`${show.episodeCount} episodes`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
web/src/components/auto-channel/PresetCard.tsx
Normal file
61
web/src/components/auto-channel/PresetCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { LiveTv, Movie, MusicNote, Shuffle } from '@mui/icons-material';
|
||||
import {
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Chip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import type { ChannelPreset, ChannelPresetCategory } from '@tunarr/types/api';
|
||||
|
||||
const categoryIcons: Record<ChannelPresetCategory, React.ReactNode> = {
|
||||
simple: <Shuffle />,
|
||||
'classic-tv': <LiveTv />,
|
||||
movie: <Movie />,
|
||||
music: <MusicNote />,
|
||||
custom: <LiveTv />,
|
||||
};
|
||||
|
||||
const categoryLabels: Record<ChannelPresetCategory, string> = {
|
||||
simple: 'Simple',
|
||||
'classic-tv': 'Classic TV',
|
||||
movie: 'Movie',
|
||||
music: 'Music',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
type PresetCardProps = {
|
||||
preset: ChannelPreset;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function PresetCard({ preset, selected, onClick }: PresetCardProps) {
|
||||
return (
|
||||
<Card
|
||||
variant={selected ? 'elevation' : 'outlined'}
|
||||
sx={{
|
||||
border: selected ? 2 : 1,
|
||||
borderColor: selected ? 'primary.main' : 'divider',
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={onClick} sx={{ p: 2 }}>
|
||||
<CardContent sx={{ textAlign: 'center', p: 1 }}>
|
||||
{categoryIcons[preset.category]}
|
||||
<Typography variant="h6" sx={{ mt: 1 }}>
|
||||
{preset.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{preset.description}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={categoryLabels[preset.category]}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
68
web/src/hooks/useAutoChannel.ts
Normal file
68
web/src/hooks/useAutoChannel.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
AutoChannelCreateRequest,
|
||||
ChannelPreset,
|
||||
ContentPreviewResponse,
|
||||
ContentQuery,
|
||||
} from '@tunarr/types/api';
|
||||
import type { Channel } from '@tunarr/types';
|
||||
import useStore from '../store/index.ts';
|
||||
|
||||
function getBackendUrl(path: string): string {
|
||||
const backendUri = useStore.getState().settings.backendUri;
|
||||
return `${backendUri}/api${path}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Request failed (${response.status}): ${body}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useAutoChannelPresets() {
|
||||
return useQuery({
|
||||
queryKey: ['auto-channel', 'presets'],
|
||||
queryFn: () =>
|
||||
fetchJson<ChannelPreset[]>(getBackendUrl('/auto-channels/presets')),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePreviewContent() {
|
||||
return useMutation({
|
||||
mutationFn: (query: ContentQuery) =>
|
||||
fetchJson<ContentPreviewResponse>(
|
||||
getBackendUrl('/auto-channels/preview-content'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(query),
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutoCreateChannel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: AutoChannelCreateRequest) =>
|
||||
fetchJson<Channel>(getBackendUrl('/auto-channels/create'), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
exact: false,
|
||||
queryKey: ['Channels'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
392
web/src/pages/channels/AutoCreateWizard.tsx
Normal file
392
web/src/pages/channels/AutoCreateWizard.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { ContentPreview } from '@/components/auto-channel/ContentPreview';
|
||||
import { PresetCard } from '@/components/auto-channel/PresetCard';
|
||||
import PaddedPaper from '@/components/base/PaddedPaper';
|
||||
import {
|
||||
useAutoChannelPresets,
|
||||
useAutoCreateChannel,
|
||||
usePreviewContent,
|
||||
} from '@/hooks/useAutoChannel';
|
||||
import { ArrowBack, ArrowForward } from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Grid,
|
||||
Step,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type {
|
||||
AutoChannelCreateRequest,
|
||||
ChannelPreset,
|
||||
ContentAssignment,
|
||||
ContentPreviewResponse,
|
||||
ContentQuery,
|
||||
} from '@tunarr/types/api';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const steps = ['Choose Style', 'Select Content', 'Review & Create'];
|
||||
|
||||
export function AutoCreateWizard() {
|
||||
const navigate = useNavigate();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [selectedPreset, setSelectedPreset] = useState<ChannelPreset | null>(
|
||||
null,
|
||||
);
|
||||
const [contentAssignments, setContentAssignments] = useState<
|
||||
Record<string, ContentAssignment>
|
||||
>({});
|
||||
const [previews, setPreviews] = useState<
|
||||
Record<string, ContentPreviewResponse>
|
||||
>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [channelNumber, setChannelNumber] = useState<number | undefined>();
|
||||
|
||||
const { data: presets, isLoading: presetsLoading } = useAutoChannelPresets();
|
||||
const previewMutation = usePreviewContent();
|
||||
const createMutation = useAutoCreateChannel();
|
||||
|
||||
// Load preview when preset is selected
|
||||
useEffect(() => {
|
||||
if (!selectedPreset) return;
|
||||
|
||||
for (const req of selectedPreset.contentRequirements) {
|
||||
const query: ContentQuery =
|
||||
contentAssignments[req.role]?.query ?? req.defaultQuery;
|
||||
previewMutation.mutate(query, {
|
||||
onSuccess: (data) => {
|
||||
setPreviews((prev) => ({ ...prev, [req.role]: data }));
|
||||
},
|
||||
});
|
||||
}
|
||||
// Only run when preset changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPreset?.id]);
|
||||
|
||||
const handlePresetSelect = useCallback((preset: ChannelPreset) => {
|
||||
setSelectedPreset(preset);
|
||||
setChannelName('');
|
||||
setChannelNumber(undefined);
|
||||
setContentAssignments({});
|
||||
setPreviews({});
|
||||
setActiveStep(1);
|
||||
}, []);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setActiveStep((prev) => Math.min(prev + 1, steps.length - 1));
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setActiveStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedPreset) return;
|
||||
|
||||
const request: AutoChannelCreateRequest = {
|
||||
presetId: selectedPreset.id,
|
||||
contentAssignments,
|
||||
channelName: channelName || undefined,
|
||||
channelNumber: channelNumber || undefined,
|
||||
};
|
||||
|
||||
createMutation.mutate(request, {
|
||||
onSuccess: (channel) => {
|
||||
navigate({
|
||||
to: '/channels/$channelId/edit',
|
||||
params: { channelId: channel.id },
|
||||
}).catch(console.error);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
selectedPreset,
|
||||
contentAssignments,
|
||||
channelName,
|
||||
channelNumber,
|
||||
createMutation,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const totalPrograms = Object.values(previews).reduce(
|
||||
(sum, p) => sum + p.totalPrograms,
|
||||
0,
|
||||
);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return (
|
||||
<ChooseStyleStep
|
||||
presets={presets ?? []}
|
||||
presetsLoading={presetsLoading}
|
||||
selectedPresetId={selectedPreset?.id}
|
||||
onSelect={handlePresetSelect}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<SelectContentStep
|
||||
preset={selectedPreset!}
|
||||
contentAssignments={contentAssignments}
|
||||
previews={previews}
|
||||
previewLoading={previewMutation.isPending}
|
||||
previewError={previewMutation.error}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<ReviewStep
|
||||
preset={selectedPreset!}
|
||||
previews={previews}
|
||||
channelName={channelName}
|
||||
channelNumber={channelNumber}
|
||||
onNameChange={setChannelName}
|
||||
onNumberChange={setChannelNumber}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>
|
||||
Auto-Create Channel
|
||||
</Typography>
|
||||
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<PaddedPaper>{renderStepContent()}</PaddedPaper>
|
||||
|
||||
{createMutation.error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Failed to create channel: {createMutation.error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 3 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={handleBack}
|
||||
disabled={activeStep === 0}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{activeStep < steps.length - 1 ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
endIcon={<ArrowForward />}
|
||||
onClick={handleNext}
|
||||
disabled={activeStep === 0 && !selectedPreset}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || totalPrograms === 0}
|
||||
startIcon={
|
||||
createMutation.isPending ? (
|
||||
<CircularProgress size={20} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Channel'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Step Components ---
|
||||
|
||||
function ChooseStyleStep({
|
||||
presets,
|
||||
presetsLoading,
|
||||
selectedPresetId,
|
||||
onSelect,
|
||||
}: {
|
||||
presets: ChannelPreset[];
|
||||
presetsLoading: boolean;
|
||||
selectedPresetId: string | undefined;
|
||||
onSelect: (preset: ChannelPreset) => void;
|
||||
}) {
|
||||
if (presetsLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Choose a channel style
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{presets.map((preset) => (
|
||||
<Grid key={preset.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<PresetCard
|
||||
preset={preset}
|
||||
selected={preset.id === selectedPresetId}
|
||||
onClick={() => onSelect(preset)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContentStep({
|
||||
preset,
|
||||
contentAssignments: _contentAssignments,
|
||||
previews,
|
||||
previewLoading,
|
||||
previewError,
|
||||
}: {
|
||||
preset: ChannelPreset;
|
||||
contentAssignments: Record<string, ContentAssignment>;
|
||||
previews: Record<string, ContentPreviewResponse>;
|
||||
previewLoading: boolean;
|
||||
previewError: Error | null;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Content for {preset.name}
|
||||
</Typography>
|
||||
|
||||
{preset.contentRequirements.map((req) => (
|
||||
<Box key={req.role} sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight="bold">
|
||||
{req.label}
|
||||
{req.required && (
|
||||
<Typography component="span" color="error" sx={{ ml: 0.5 }}>
|
||||
*
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
{req.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{req.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{req.defaultQuery.programTypes && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Default: {req.defaultQuery.programTypes.join(', ')}
|
||||
</Typography>
|
||||
)}
|
||||
{!req.defaultQuery.programTypes &&
|
||||
!req.defaultQuery.filterString &&
|
||||
!req.defaultQuery.keywords && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Default: All content
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<ContentPreview
|
||||
preview={previews[req.role]}
|
||||
isLoading={previewLoading && !previews[req.role]}
|
||||
error={!previews[req.role] ? previewError : null}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewStep({
|
||||
preset,
|
||||
previews,
|
||||
channelName,
|
||||
channelNumber,
|
||||
onNameChange,
|
||||
onNumberChange,
|
||||
}: {
|
||||
preset: ChannelPreset;
|
||||
previews: Record<string, ContentPreviewResponse>;
|
||||
channelName: string;
|
||||
channelNumber: number | undefined;
|
||||
onNameChange: (name: string) => void;
|
||||
onNumberChange: (num: number | undefined) => void;
|
||||
}) {
|
||||
const totalPrograms = Object.values(previews).reduce(
|
||||
(sum, p) => sum + p.totalPrograms,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Review & Create
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
||||
<TextField
|
||||
label="Channel Name"
|
||||
value={channelName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder={preset.name}
|
||||
helperText="Leave blank to auto-generate"
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Channel Number"
|
||||
type="number"
|
||||
value={channelNumber ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
onNumberChange(isNaN(val) ? undefined : val);
|
||||
}}
|
||||
helperText="Leave blank to auto-assign"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Preset
|
||||
</Typography>
|
||||
<Typography>{preset.name}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Schedule Type
|
||||
</Typography>
|
||||
<Typography>
|
||||
{preset.scheduleType === 'random' ? 'Random Slots' : 'Time Slots'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Content
|
||||
</Typography>
|
||||
<Typography>
|
||||
{totalPrograms} program{totalPrograms !== 1 ? 's' : ''} across{' '}
|
||||
{preset.contentRequirements.length} role
|
||||
{preset.contentRequirements.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterHumanize } from '@/helpers/dayjs.ts';
|
||||
import { useTranscodeConfigs } from '@/hooks/settingsHooks.ts';
|
||||
import type { Maybe } from '@/types/util.ts';
|
||||
import { Check, Close, Edit, MoreVert } from '@mui/icons-material';
|
||||
import { AutoFixHigh, Check, Close, Edit, MoreVert } from '@mui/icons-material';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import type { BoxProps } from '@mui/material';
|
||||
import {
|
||||
@@ -565,13 +565,22 @@ export default function ChannelsPage() {
|
||||
<Typography flexGrow={1} variant="h3">
|
||||
Channels
|
||||
</Typography>
|
||||
<RouterButtonLink
|
||||
to="/channels/new"
|
||||
variant="contained"
|
||||
startIcon={<AddCircleIcon />}
|
||||
>
|
||||
New
|
||||
</RouterButtonLink>
|
||||
<Box display="flex" gap={1}>
|
||||
<RouterButtonLink
|
||||
to="/channels/auto-create"
|
||||
variant="outlined"
|
||||
startIcon={<AutoFixHigh />}
|
||||
>
|
||||
Auto Create
|
||||
</RouterButtonLink>
|
||||
<RouterButtonLink
|
||||
to="/channels/new"
|
||||
variant="contained"
|
||||
startIcon={<AddCircleIcon />}
|
||||
>
|
||||
New
|
||||
</RouterButtonLink>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{smallViewport ? (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ const editChannelParamsSchema = z.object({
|
||||
tab: z.enum(['flex', 'epg', 'ffmpeg']).optional().catch(undefined),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId/edit/')({
|
||||
export const Route = createFileRoute('/channels/$channelId/edit/')({
|
||||
validateSearch: (search) => editChannelParamsSchema.parse(search),
|
||||
loader: async ({ params, context }) => {
|
||||
const channel = await context.queryClient.ensureQueryData(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ChannelSummaryPage } from '../../../pages/channels/ChannelSummaryPage.tsx';
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId/')({
|
||||
export const Route = createFileRoute('/channels/$channelId/')({
|
||||
loader: preloadChannelAndProgramming,
|
||||
component: ChannelSummaryPage,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ const channelProgrammingSchema = z.object({
|
||||
libraryId: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId/programming/add')({
|
||||
export const Route = createFileRoute('/channels/$channelId/programming/add')({
|
||||
validateSearch: (search) => channelProgrammingSchema.parse(search),
|
||||
loader: async (args: ChannelArgs) => {
|
||||
useStore.setState((s) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { preloadChannelAndProgramming } from '@/helpers/routeLoaders';
|
||||
import ChannelProgrammingPage from '@/pages/channels/ChannelProgrammingPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId/programming/')({
|
||||
export const Route = createFileRoute('/channels/$channelId/programming/')({
|
||||
loader: preloadChannelAndProgramming,
|
||||
component: () => <ChannelProgrammingPage />,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import RandomSlotEditorPage from '@/pages/channels/RandomSlotEditorPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/channels_/$channelId/programming/slot-editor',
|
||||
'/channels/$channelId/programming/slot-editor',
|
||||
)({
|
||||
loader: preloadChannelAndProgramming,
|
||||
component: RandomSlotEditorPage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import TimeSlotEditorPage from '@/pages/channels/TimeSlotEditorPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/channels_/$channelId/programming/time-slot-editor',
|
||||
'/channels/$channelId/programming/time-slot-editor',
|
||||
)({
|
||||
loader: preloadChannelAndProgramming,
|
||||
component: TimeSlotEditorPage,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId')({
|
||||
export const Route = createFileRoute('/channels/$channelId')({
|
||||
loader() {
|
||||
setCurrentEntityType('channel');
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const watchPageSearchSchema = z.object({
|
||||
.catch(true),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/channels_/$channelId/watch')({
|
||||
export const Route = createFileRoute('/channels/$channelId/watch')({
|
||||
validateSearch: (s) => watchPageSearchSchema.parse(s),
|
||||
loader: ({ params: { channelId }, context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(channelQuery(channelId)),
|
||||
|
||||
6
web/src/routes/channels_/auto-create.tsx
Normal file
6
web/src/routes/channels_/auto-create.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AutoCreateWizard } from '@/pages/channels/AutoCreateWizard';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/channels/auto-create')({
|
||||
component: () => <AutoCreateWizard />,
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import ChannelsPage from '@/pages/channels/ChannelsPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { getChannelsOptions } from '../../generated/@tanstack/react-query.gen.ts';
|
||||
|
||||
export const Route = createFileRoute('/channels_/')({
|
||||
export const Route = createFileRoute('/channels/')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(getChannelsOptions()),
|
||||
component: ChannelsPage,
|
||||
|
||||
@@ -29,7 +29,7 @@ const editChannelParamsSchema = z.object({
|
||||
.catch(undefined),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/channels_/new')({
|
||||
export const Route = createFileRoute('/channels/new')({
|
||||
validateSearch: (search) => editChannelParamsSchema.parse(search),
|
||||
loader: async ({ context }) => {
|
||||
const transcodeConfigs = await context.queryClient.ensureQueryData(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/channels_/test')({
|
||||
export const Route = createFileRoute('/channels/test')({
|
||||
component: () => <div>Test</div>,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { preloadCustomShowAndProgramming } from '@/helpers/routeLoaders.ts';
|
||||
import EditCustomShowPage from '@/pages/library/EditCustomShowPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/library/custom-shows_/$showId/edit')({
|
||||
export const Route = createFileRoute('/library/custom-shows/$showId/edit')({
|
||||
loader: preloadCustomShowAndProgramming,
|
||||
component: EditCustomShowPage,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { noop } from 'ts-essentials';
|
||||
import { ProgrammingSelectionContext } from '../../../../context/ProgrammingSelectionContext.ts';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/library/custom-shows_/$showId/programming',
|
||||
'/library/custom-shows/$showId/programming',
|
||||
)({
|
||||
loader: preloadCustomShowAndProgramming,
|
||||
component: CustomShowProgrammingSelectorPage,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/custom-shows_/$showId')({
|
||||
export const Route = createFileRoute('/library/custom-shows/$showId')({
|
||||
loader() {
|
||||
setCurrentEntityType('custom-show');
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import useStore from '@/store';
|
||||
import { setCurrentCustomShow } from '@/store/customShowEditor/actions';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/library/custom-shows_/new/')({
|
||||
export const Route = createFileRoute('/library/custom-shows/new/')({
|
||||
loader() {
|
||||
const customShow = {
|
||||
id: UnsavedId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { noop } from 'lodash-es';
|
||||
import { ProgrammingSelectionContext } from '../../../../context/ProgrammingSelectionContext.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/custom-shows_/new/programming')({
|
||||
export const Route = createFileRoute('/library/custom-shows/new/programming')({
|
||||
component: CustomShowProgrammingSelectorPage,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/custom-shows_/new')({
|
||||
export const Route = createFileRoute('/library/custom-shows/new')({
|
||||
loader() {
|
||||
setCurrentEntityType('custom-show');
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import EditFillerPage from '@/pages/library/EditFillerPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/$fillerId/edit')({
|
||||
export const Route = createFileRoute('/library/fillers/$fillerId/edit')({
|
||||
loader: (context) => {
|
||||
setCurrentEntityType('filler');
|
||||
return preloadFillerAndProgramming(context);
|
||||
|
||||
@@ -5,12 +5,10 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { noop } from 'ts-essentials';
|
||||
import { ProgrammingSelectionContext } from '../../../../context/ProgrammingSelectionContext.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/$fillerId/programming')(
|
||||
{
|
||||
loader: preloadFillerAndProgramming,
|
||||
component: FillerProgrammingSelectorPage,
|
||||
},
|
||||
);
|
||||
export const Route = createFileRoute('/library/fillers/$fillerId/programming')({
|
||||
loader: preloadFillerAndProgramming,
|
||||
component: FillerProgrammingSelectorPage,
|
||||
});
|
||||
|
||||
function FillerProgrammingSelectorPage() {
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/$fillerId')({
|
||||
export const Route = createFileRoute('/library/fillers/$fillerId')({
|
||||
loader() {
|
||||
setCurrentEntityType('filler');
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import useStore from '@/store';
|
||||
import { setCurrentFillerList } from '@/store/fillerListEditor/action';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/new/')({
|
||||
export const Route = createFileRoute('/library/fillers/new/')({
|
||||
loader: () => {
|
||||
const unsavedData = {
|
||||
fillerList: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
||||
import { noop } from 'ts-essentials';
|
||||
import { ProgrammingSelectionContext } from '../../../../context/ProgrammingSelectionContext.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/new/programming')({
|
||||
export const Route = createFileRoute('/library/fillers/new/programming')({
|
||||
component: FillerProgrammingSelectorPage,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { setCurrentEntityType } from '../../../../store/channelEditor/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/library/fillers_/new')({
|
||||
export const Route = createFileRoute('/library/fillers/new')({
|
||||
loader() {
|
||||
setCurrentEntityType('filler');
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { TrashPage } from '../../../pages/library/TrashPage.tsx';
|
||||
|
||||
export const Route = createFileRoute('/library/trash_/')({
|
||||
export const Route = createFileRoute('/library/trash/')({
|
||||
component: TrashPage,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ProgramPage } from '@/pages/media/ProgramPage';
|
||||
import { createFileRoute, notFound } from '@tanstack/react-router';
|
||||
import { isGroupingItemType, isTerminalItemType } from '@tunarr/types';
|
||||
|
||||
export const Route = createFileRoute('/media_/$programType/$programId')({
|
||||
export const Route = createFileRoute('/media/$programType/$programId')({
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
beforeLoad: async ({ params }) => {
|
||||
const { programType, programId } = params;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getApiMediaSourcesByMediaSourceIdOptions } from '../../../generated/@ta
|
||||
import { useMediaSource } from '../../../hooks/media-sources/mediaSourceHooks.ts';
|
||||
import { setSearchRequest } from '../../../store/programmingSelector/actions.ts';
|
||||
|
||||
export const Route = createFileRoute('/media_sources_/$mediaSourceId/')({
|
||||
export const Route = createFileRoute('/media_sources/$mediaSourceId/')({
|
||||
component: MediaSourceBrowserPage,
|
||||
loader: async ({ context, params: { mediaSourceId } }) => {
|
||||
await context.queryClient.ensureQueryData(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { setSearchRequest } from '../../../store/programmingSelector/actions.ts';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/media_sources_/$mediaSourceId/libraries_/$libraryId',
|
||||
'/media_sources/$mediaSourceId/libraries/$libraryId',
|
||||
)({
|
||||
component: MediaSourceBrowserPage,
|
||||
loader: async ({ context, params: { libraryId } }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import MediaSourceSettingsPage from '../../pages/settings/MediaSourceSettingsPage.tsx';
|
||||
|
||||
export const Route = createFileRoute('/media_sources_/')({
|
||||
export const Route = createFileRoute('/media_sources/')({
|
||||
component: MediaSourceSettingsPage,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EditTranscodeConfigSettingsPage } from '@/pages/settings/EditTranscodeC
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { getApiTranscodeConfigsByIdOptions } from '../../../generated/@tanstack/react-query.gen.ts';
|
||||
|
||||
export const Route = createFileRoute('/settings/ffmpeg_/$configId')({
|
||||
export const Route = createFileRoute('/settings/ffmpeg/$configId')({
|
||||
loader: ({ params, context }) => {
|
||||
return context.queryClient.ensureQueryData(
|
||||
getApiTranscodeConfigsByIdOptions({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NewTranscodeConfigSettingsPage } from '@/pages/settings/NewTranscodeConfigSettingsPage';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/settings/ffmpeg_/new')({
|
||||
export const Route = createFileRoute('/settings/ffmpeg/new')({
|
||||
component: NewTranscodeConfigSettingsPage,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user