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 { // 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> { 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 { // 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 { 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();