feat: experimental auto channel creation

This commit is contained in:
Christian Benincasa
2026-04-13 17:44:35 -04:00
parent fcb3ce8b09
commit 6480ad0002
44 changed files with 2323 additions and 1168 deletions

View 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 });
}
},
);
};
}

View File

@@ -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',

View 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;
}
}

View File

@@ -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",

View 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);
}

View 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
>;

View File

@@ -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';

View 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>
);
}

View 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>
);
}

View 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'],
});
},
});
}

View 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>
);
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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,
});

View File

@@ -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) => {

View File

@@ -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 />,
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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');
},

View File

@@ -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)),

View 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 />,
});

View File

@@ -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,

View File

@@ -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(

View File

@@ -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>,
});

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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');
},

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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');
},

View File

@@ -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);

View File

@@ -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();

View File

@@ -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');
},

View File

@@ -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: {

View File

@@ -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,
});

View File

@@ -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');
},

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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(

View File

@@ -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 } }) => {

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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,
});