929 lines
25 KiB
TypeScript

import type PlexAPI from '@server/api/plexapi';
import type { TmdbFranchiseSourceData } from '@server/lib/collections/core/types';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
/**
* Template placeholders that can be used in collection names
*/
export interface TemplateContext {
mediaType?: 'movie' | 'tv';
days?: number;
customdays?: number;
statType?: string;
servername?: string;
subtype?: string;
domain?: string;
nickname?: string;
username?: string;
displayName?: string;
// User-specific placeholders for compatibility with templateUtils
user?: string; // User display name (for {user} placeholder)
appTitle?: string; // Application title (for {appTitle} placeholder)
// Time-based placeholders
currentDate?: string; // Current date in DD-MM format
currentMonth?: string; // Current month name
currentYear?: string; // Current year
currentDay?: string; // Current day name
isWeekend?: boolean; // Whether current day is weekend
// Franchise-specific placeholders
franchiseName?: string; // TMDB collection/franchise name
franchiseId?: number; // TMDB collection ID
movieCount?: number; // Number of movies in franchise
// Person-specific placeholders (actor/director collections)
actor?: string; // Actor name for actor collections
director?: string; // Director name for director collections
}
/**
* Template Engine for processing collection name templates
*
* Handles placeholder replacement for collection names across all sync sources.
* Supports template inheritance where custom templates can override base templates.
*/
export class TemplateEngine {
private settings = getSettings();
/**
* Process a template with the given context
*
* @param template - The template string with placeholders like {mediaType}
* @param context - Context object containing values for placeholder replacement
* @returns Processed template with placeholders replaced
*/
public processTemplate(template: string, context: TemplateContext): string {
if (!template) {
return '';
}
// Debug logging to see what context we're getting
logger.debug('Template processing', {
label: 'Template Engine',
template,
context: {
domain: context.domain,
appTitle: context.appTitle,
nickname: context.nickname,
username: context.username,
servername: context.servername,
mediaType: context.mediaType,
},
});
let processed = template;
// Replace all known placeholders
if (context.mediaType !== undefined) {
const mediaTypeLabel = this.getMediaTypeLabel(context.mediaType);
const mediaTypePluralLabel = this.getMediaTypePluralLabel(
context.mediaType
);
// Replace {mediaType}s first (plural), then {mediaType} (singular)
processed = processed.replace(/{mediaType}s/g, mediaTypePluralLabel);
processed = processed.replace(/{mediaType}/g, mediaTypeLabel);
}
if (context.days !== undefined) {
processed = processed.replace(/{days}/g, context.days.toString());
}
if (context.customdays !== undefined) {
processed = processed.replace(
/{customdays}/g,
context.customdays.toString()
);
}
if (context.statType !== undefined) {
processed = processed.replace(/{statType}/g, context.statType);
}
if (context.servername !== undefined) {
processed = processed.replace(/{servername}/g, context.servername);
}
if (context.subtype !== undefined) {
processed = processed.replace(/{subtype}/g, context.subtype);
}
if (context.domain !== undefined) {
processed = processed.replace(/{domain}/g, context.domain);
}
if (context.nickname !== undefined) {
processed = processed.replace(/{nickname}/g, context.nickname);
}
if (context.username !== undefined) {
processed = processed.replace(/{username}/g, context.username);
}
if (context.displayName !== undefined) {
processed = processed.replace(/{displayName}/g, context.displayName);
}
// User-specific placeholders for compatibility with templateUtils
if (context.user !== undefined) {
processed = processed.replace(/{user}/g, context.user);
}
if (context.appTitle !== undefined) {
processed = processed.replace(/{appTitle}/g, context.appTitle);
}
// Time-based placeholders
if (context.currentDate !== undefined) {
processed = processed.replace(/{currentDate}/g, context.currentDate);
}
if (context.currentMonth !== undefined) {
processed = processed.replace(/{currentMonth}/g, context.currentMonth);
}
if (context.currentYear !== undefined) {
processed = processed.replace(/{currentYear}/g, context.currentYear);
}
if (context.currentDay !== undefined) {
processed = processed.replace(/{currentDay}/g, context.currentDay);
}
if (context.isWeekend !== undefined) {
processed = processed.replace(
/{isWeekend}/g,
context.isWeekend ? 'Weekend' : 'Weekday'
);
}
// Franchise-specific placeholders
if (context.franchiseName !== undefined) {
processed = processed.replace(/{franchiseName}/g, context.franchiseName);
}
// Person-specific placeholders (actor/director collections)
if (context.actor !== undefined) {
processed = processed.replace(/{actor}/g, context.actor);
}
if (context.director !== undefined) {
processed = processed.replace(/{director}/g, context.director);
}
// Debug logging to see the final result
logger.debug('Template processing result', {
label: 'Template Engine',
originalTemplate: template,
processedTemplate: processed,
wasChanged: template !== processed,
});
return processed;
}
/**
* Process template with media type-specific custom templates
*
* @param baseTemplate - Base template to use
* @param customMovieTemplate - Custom template to use for movies (when mediaType is 'both')
* @param customTVTemplate - Custom template to use for TV shows (when mediaType is 'both')
* @param context - Template context
* @returns Processed template
*/
public processTemplateWithCustom(
baseTemplate: string,
customMovieTemplate: string | undefined,
customTVTemplate: string | undefined,
context: TemplateContext
): string {
let templateToUse = baseTemplate;
// Use custom templates when processing 'both' media types
if (context.mediaType === 'movie' && customMovieTemplate) {
templateToUse = customMovieTemplate;
} else if (context.mediaType === 'tv' && customTVTemplate) {
templateToUse = customTVTemplate;
}
return this.processTemplate(templateToUse, context);
}
/**
* Get default template context values from settings
*/
public getDefaultContext(): Partial<TemplateContext> {
// Use external service data if available, fallback to local settings
const domainUrl =
this.settings.main.externalApplicationUrl ||
this.settings.main.applicationUrl ||
'';
const appTitle =
this.settings.main.externalApplicationTitle ||
this.settings.main.applicationTitle ||
'Overseerr';
return {
servername: this.settings.plex.name || 'Plex Server',
domain: this.extractDomainFromUrl(domainUrl),
appTitle: appTitle,
// Include stored admin info for user template examples
username: this.settings.main.adminUsername || 'username',
nickname: this.settings.main.adminNickname || 'nickname',
};
}
/**
* Get default context with external Overseerr settings for template variables
* Used specifically for Overseerr collections to pull domain/appTitle from external instance
*/
public async getOverseerrDefaultContext(): Promise<Partial<TemplateContext>> {
try {
// Import the service to avoid circular dependencies
const { overseerrCollectionService } = await import(
'@server/lib/collections/sources/overseerr'
);
const overseerrSettings =
await overseerrCollectionService.getOverseerrSettings();
if (overseerrSettings) {
return {
servername: this.settings.plex.name || 'Plex Server',
domain: this.extractDomainFromUrl(
overseerrSettings.applicationUrl || ''
),
appTitle: overseerrSettings.applicationTitle || 'Overseerr',
};
}
} catch (error) {
// Fall back to local settings if external fetch fails
logger.warn(
'Failed to fetch external Overseerr settings for template context, using local settings',
{
label: 'TemplateEngine',
error: error instanceof Error ? error.message : String(error),
}
);
}
// Fallback to local settings
return this.getDefaultContext();
}
/**
* Create context for Tautulli collections
*/
public createTautulliContext(
mediaType: 'movie' | 'tv',
timeRangeDays: number,
statType: string,
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
days: timeRangeDays,
customdays: timeRangeDays,
statType: this.getStatTypeLabel(statType),
subtype: this.getTautulliSubtypeLabel(subtype),
};
}
/**
* Create context for Trakt collections
*/
public createTraktContext(
mediaType: 'movie' | 'tv',
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getTraktSubtypeLabel(subtype),
};
}
/**
* Create context for AniList collections
*/
public createAnilistContext(
mediaType: 'movie' | 'tv',
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getAnilistSubtypeLabel(subtype),
};
}
/**
* Create context for MyAnimeList collections
*/
public createMyAnimeListContext(
mediaType: 'movie' | 'tv',
rankingType: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getMyAnimeListSubtypeLabel(rankingType),
};
}
/**
* Create context for MDBList collections
*/
public createMDBListContext(
mediaType: 'movie' | 'tv',
listType: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getMDBListSubtypeLabel(listType),
};
}
/**
* Create context for TMDB collections
*/
public createTmdbContext(
mediaType: 'movie' | 'tv',
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getTmdbSubtypeLabel(subtype),
};
}
/**
* Create context for TMDB franchise collections
*/
public createFranchiseContext(
franchiseData: TmdbFranchiseSourceData
): TemplateContext {
// Strip " Collection" suffix from franchise name for cleaner templates
// e.g., "Moana Collection" -> "Moana"
const cleanFranchiseName = franchiseData.franchiseName.replace(
/ Collection$/i,
''
);
return {
...this.getDefaultContext(),
franchiseName: cleanFranchiseName,
franchiseId: franchiseData.franchiseId,
movieCount: franchiseData.movies.length,
mediaType: 'movie' as const,
};
}
/**
* Create context for IMDb collections
*/
public createImdbContext(
mediaType: 'movie' | 'tv',
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getImdbSubtypeLabel(subtype),
};
}
/**
* Create context for Letterboxd collections
*/
public createLetterboxdContext(
mediaType: 'movie' | 'tv',
subtype: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getLetterboxdSubtypeLabel(subtype),
};
}
/**
* Create context for Networks collections
*/
public createNetworksContext(
mediaType: 'movie' | 'tv',
platform: string,
statType: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getNetworksSubtypeLabel(platform),
statType,
};
}
/**
* Create context for Originals collections
*/
public createOriginalsContext(
mediaType: 'movie' | 'tv',
platform: string
): TemplateContext {
return {
...this.getDefaultContext(),
mediaType,
subtype: this.getOriginalsSubtypeLabel(platform),
};
}
/**
* Create context for Overseerr collections
*/
public createOverseerrContext(
mediaType: 'movie' | 'tv',
user: {
displayName?: string;
username?: string;
plexUsername?: string;
plexTitle?: string;
email?: string;
plexId?: number;
id?: number;
}
): TemplateContext {
// Calculate user display name using the same logic as templateUtils
const userDisplayName =
user.displayName ||
user.plexUsername ||
user.username ||
user.email ||
`User ${user.plexId || user.id}`;
const context: TemplateContext = {
...this.getDefaultContext(),
mediaType,
nickname:
user.plexTitle ||
user.displayName ||
user.username ||
user.plexUsername ||
'User',
username: user.username || user.plexUsername || 'User',
displayName:
user.displayName || user.username || user.plexUsername || 'User',
user: userDisplayName, // Add {user} placeholder support
};
return context;
}
/**
* Create enhanced context for Overseerr collections with external data
* Fetches domain/appTitle from external Overseerr and plexTitle from Plex
*/
public async createEnhancedOverseerrContext(
mediaType: 'movie' | 'tv',
user: {
displayName?: string;
username?: string;
plexUsername?: string;
plexTitle?: string;
email?: string;
plexId?: number;
id?: number;
},
plexClient?: PlexAPI,
isServerOwner?: boolean
): Promise<TemplateContext> {
// Get external Overseerr settings for domain/appTitle
const defaultContext = await this.getOverseerrDefaultContext();
// Try to fetch plexTitle from Plex if we have a plexId and plexClient
const enhancedUser = { ...user };
if (plexClient && user.plexId) {
try {
const plexTitle = await plexClient.getPlexUserTitle(
user.plexId.toString()
);
if (plexTitle) {
enhancedUser.plexTitle = plexTitle;
}
} catch (error) {
logger.warn(`Failed to fetch plexTitle for user ${user.plexId}`, {
label: 'TemplateEngine',
error: error instanceof Error ? error.message : String(error),
});
}
}
// Calculate user display name using the same logic as templateUtils
const userDisplayName =
enhancedUser.displayName ||
enhancedUser.plexUsername ||
enhancedUser.username ||
enhancedUser.email ||
`User ${enhancedUser.plexId || enhancedUser.id}`;
// Check if this is the admin user - use adminNickname from settings
// For server_owner collections, always use admin nickname from settings
const isAdminUser =
isServerOwner ||
enhancedUser.username === this.settings.main.adminUsername;
// Debug logging to understand admin user detection
logger.debug('Admin user detection in TemplateEngine', {
label: 'TemplateEngine',
userUsername: enhancedUser.username,
adminUsername: this.settings.main.adminUsername,
isAdminUser: isAdminUser,
isServerOwner: isServerOwner,
adminNickname: this.settings.main.adminNickname,
});
const context: TemplateContext = {
...defaultContext,
mediaType,
nickname: isAdminUser
? this.settings.main.adminNickname || 'Admin'
: enhancedUser.plexTitle ||
enhancedUser.displayName ||
enhancedUser.username ||
enhancedUser.plexUsername ||
'User',
username: enhancedUser.username || enhancedUser.plexUsername || 'User',
displayName:
enhancedUser.displayName ||
enhancedUser.username ||
enhancedUser.plexUsername ||
'User',
user: userDisplayName, // Add {user} placeholder support
};
return context;
}
/**
* Create context for global collections (no specific user)
* Equivalent to generateGlobalCollectionName() logic
*/
public createGlobalContext(mediaType?: 'movie' | 'tv'): TemplateContext {
const context: TemplateContext = {
...this.getDefaultContext(),
mediaType,
};
return context;
}
/**
* Convert media type to human-readable label
*/
private getMediaTypeLabel(mediaType: 'movie' | 'tv'): string {
switch (mediaType) {
case 'movie':
return 'Movie';
case 'tv':
return 'TV Show';
default:
return 'Media';
}
}
/**
* Convert media type to human-readable plural label
*/
private getMediaTypePluralLabel(mediaType: 'movie' | 'tv'): string {
switch (mediaType) {
case 'movie':
return 'Movies';
case 'tv':
return 'TV Shows';
default:
return 'Media';
}
}
/**
* Convert stat type to readable label
*/
private getStatTypeLabel(statType: string): string {
switch (statType) {
case 'plays':
return 'Play Count';
case 'duration':
return 'Watch Duration';
default:
return statType;
}
}
/**
* Get readable label for Tautulli subtype
*/
private getTautulliSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'most_popular_plays':
case 'most_popular_duration':
return 'Most Popular';
case 'most_watched_plays':
case 'most_watched_duration':
return 'Most Watched';
default:
return subtype;
}
}
/**
* Get human-readable label for Trakt subtype
*/
private getTraktSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'trending':
return 'Trending Last 7 Days';
case 'popular':
return 'Popular';
case 'played':
return 'Most Played';
case 'watched':
return 'Most Watched';
case 'collected':
return 'Most Collected';
case 'boxoffice':
return 'Box Office';
case 'recommendations':
return 'Recommendations';
case 'watchlist':
return 'Watchlist';
case 'custom':
return 'Custom List';
default:
return subtype
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for Anilist subtype
*/
private getAnilistSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'trending':
return 'Trending Anime';
case 'popular':
return 'Popular Anime';
case 'top_rated':
return 'Top Rated Anime';
case 'custom':
return 'Custom List';
default:
return subtype
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for MyAnimeList ranking type
*/
private getMyAnimeListSubtypeLabel(rankingType: string): string {
switch (rankingType) {
case 'all':
return 'Top Anime';
case 'airing':
return 'Top Airing Anime';
case 'tv':
return 'Top Anime TV Series';
case 'ova':
return 'Top OVA Series';
case 'movie':
return 'Top Anime Movies';
case 'special':
return 'Top Anime Specials';
case 'bypopularity':
return 'Most Popular Anime';
case 'favorite':
return 'Most Favorited Anime';
default:
return rankingType
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for MDBList list type
*/
private getMDBListSubtypeLabel(listType: string): string {
switch (listType) {
case 'top':
case 'top_lists':
return 'Top Lists';
case 'user_lists':
return 'User Lists';
case 'custom':
return 'Custom List';
default:
return listType
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for TMDB subtype
*/
private getTmdbSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'trending_day':
return 'Trending Today';
case 'trending_week':
return 'Trending This Week';
case 'popular':
return 'Popular';
case 'top_rated':
return 'Top Rated';
case 'custom':
return 'Custom Collection';
default:
return subtype
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for IMDb subtype
*/
private getImdbSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'top_250':
return 'Top 250';
case 'top_250_english':
return 'Top 250 English';
case 'popular':
return 'Popular';
case 'most_popular':
return 'Most Popular';
case 'custom':
return 'Custom List';
default:
return subtype
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for Letterboxd subtype
*/
private getLetterboxdSubtypeLabel(subtype: string): string {
switch (subtype) {
case 'custom':
return 'Custom List';
default:
return subtype
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
}
/**
* Get human-readable label for Networks platform
*/
private getNetworksSubtypeLabel(platform: string): string {
switch (platform) {
case 'netflix':
return 'Netflix';
case 'hbo':
return 'HBO';
case 'disney':
return 'Disney+';
case 'amazon-prime':
return 'Amazon Prime';
case 'apple-tv':
return 'Apple TV+';
case 'paramount':
return 'Paramount+';
case 'peacock':
return 'Peacock';
case 'crunchyroll':
return 'Crunchyroll';
case 'discovery-plus':
return 'Discovery+';
case 'hulu':
return 'Hulu';
default:
return platform
.replace(/_/g, ' ')
.replace(/-/g, ' ')
.split(' ')
.map((word) => {
// Special case for TV to maintain proper capitalization
if (word.toLowerCase() === 'tv') {
return 'TV';
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
}
/**
* Get human-readable label for Originals platform
*/
private getOriginalsSubtypeLabel(platform: string): string {
// Reuse Networks labeling since they use the same platforms
return this.getNetworksSubtypeLabel(platform);
}
/**
* Extract domain name from URL
*/
private extractDomainFromUrl(url: string): string {
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname;
} catch {
// Fallback for invalid URLs
return url.replace(/^https?:\/\//, '').split('/')[0];
}
}
/**
* Create time-based template context with current date/time information
*
* @param currentDate - Optional date to use (defaults to current date)
* @returns TemplateContext with time-based placeholders
*/
public createTimeBasedContext(currentDate?: Date): Partial<TemplateContext> {
const now = currentDate || new Date();
const day = now.getDate().toString().padStart(2, '0');
const month = (now.getMonth() + 1).toString().padStart(2, '0');
const year = now.getFullYear().toString();
const monthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const dayOfWeek = now.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
return {
currentDate: `${day}-${month}`,
currentMonth: monthNames[now.getMonth()],
currentYear: year,
currentDay: dayNames[dayOfWeek],
isWeekend,
};
}
/**
* Enhance any template context with time-based information
*
* @param baseContext - Base template context
* @param currentDate - Optional date to use (defaults to current date)
* @returns Enhanced context with time-based placeholders
*/
public enhanceContextWithTime(
baseContext: TemplateContext,
currentDate?: Date
): TemplateContext {
const timeContext = this.createTimeBasedContext(currentDate);
return {
...baseContext,
...timeContext,
};
}
}
// Export singleton instance
export const templateEngine = new TemplateEngine();