import type { PlexHubManagementResponse } from '@server/interfaces/api/plexInterfaces'; import PlexHubManager from '@server/lib/collections/plex/PlexHubManager'; import PlexPosterManager from '@server/lib/collections/plex/PlexPosterManager'; import PlexSmartCollectionManager from '@server/lib/collections/plex/PlexSmartCollectionManager'; import type { Library, PlexSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import NodePlexAPI from 'plex-api'; // Extended interface for type-safe Plex API HTTP methods interface ExtendedPlexAPI extends NodePlexAPI { postQuery?: (url: string) => Promise; putQuery?: (url: string) => Promise; deleteQuery?: (url: string) => Promise; } export interface PlexLibraryItem { ratingKey: string; parentRatingKey?: string; grandparentRatingKey?: string; title: string; guid: string; parentGuid?: string; grandparentGuid?: string; addedAt: number; updatedAt: number; lastViewedAt?: number; viewCount?: number; year?: number; originallyAvailableAt?: string; // Original release date (YYYY-MM-DD format) index?: number; parentIndex?: number; editionTitle?: string; userRating?: number; // User rating from server admin/user (0-10 scale where 10 = 5 stars) Guid?: { id: string; }[]; type: 'movie' | 'show' | 'season' | 'episode'; Media: Media[]; } interface PlexLibraryResponse { MediaContainer: { totalSize: number; Metadata: PlexLibraryItem[]; }; } export interface PlexLibrary { type: 'show' | 'movie'; key: string; title: string; agent: string; } interface PlexLibrariesResponse { MediaContainer: { Directory: PlexLibrary[]; }; } export interface PlexMetadata { ratingKey: string; parentRatingKey?: string; guid: string; type: 'movie' | 'show' | 'season' | 'episode'; title: string; thumb?: string; Guid: { id: string; }[]; Children?: { size: 12; Metadata: PlexMetadata[]; }; index: number; parentIndex?: number; leafCount: number; viewedLeafCount: number; addedAt: number; updatedAt: number; lastViewedAt?: number; viewCount?: number; Media: Media[]; } interface PlexStream { id: number; streamType: number; // 1=video, 2=audio, 3=subtitle codec: string; // Video stream fields DOVIPresent?: boolean; DOVIProfile?: number; // Dolby Vision profile (5, 7, 8, etc.) DOVILevel?: number; DOVIVersion?: string; DOVIBLPresent?: boolean; DOVIELPresent?: boolean; DOVIRPUPresent?: boolean; DOVIBLCompatID?: number; height?: number; width?: number; colorPrimaries?: string; colorSpace?: string; colorTrc?: string; bitDepth?: number; chromaSubsampling?: string; // Audio stream fields channels?: number; audioChannelLayout?: string; displayTitle?: string; language?: string; languageCode?: string; // Subtitle stream fields format?: string; forced?: boolean; } interface PlexPart { id: number; file: string; size: number; Stream?: PlexStream[]; } interface Media { id: number; duration: number; bitrate: number; width: number; height: number; aspectRatio: number; audioChannels: number; audioCodec: string; videoCodec: string; videoResolution: string; container: string; videoFrameRate: string; videoProfile: string; Part?: PlexPart[]; } interface PlexMetadataResponse { MediaContainer: { Metadata: PlexMetadata[]; }; } export interface PlexCollectionItem { ratingKey: string; title: string; addedAt?: number; [key: string]: unknown; } interface PlexCollection { ratingKey: string; title: string; type: string; addedAt?: number; labels: string[]; libraryKey?: string; libraryName?: string; titleSort?: string; Label?: { tag: string; id?: number }[]; [key: string]: unknown; } interface PlexCollectionMetadata extends PlexCollection { summary?: string; childCount?: number; thumb?: string; art?: string; titleSort?: string; smart?: string; // Smart collections have smart="1" attribute (Plex returns string) } interface PlexCollectionResponse { MediaContainer: { Metadata: PlexCollection[]; size?: number; totalSize?: number; }; } class PlexAPI { private plexClient: NodePlexAPI; private plexToken?: string; private hubManager: PlexHubManager; private smartCollectionManager: PlexSmartCollectionManager; private posterManager: PlexPosterManager; private getExtendedClient(): ExtendedPlexAPI { return this.plexClient as ExtendedPlexAPI; } private async safePostQuery(url: string): Promise { const client = this.getExtendedClient(); if (typeof client.postQuery !== 'function') { throw new Error( 'POST operations are not supported by this Plex API version' ); } return client.postQuery(url); } private async safePutQuery(url: string): Promise { const client = this.getExtendedClient(); if (typeof client.putQuery !== 'function') { throw new Error( 'PUT operations are not supported by this Plex API version' ); } return client.putQuery(url); } private async safeDeleteQuery(url: string): Promise { const client = this.getExtendedClient(); if (typeof client.deleteQuery !== 'function') { throw new Error( 'DELETE operations are not supported by this Plex API version' ); } return client.deleteQuery(url); } constructor({ plexToken, plexSettings, timeout, }: { plexToken?: string; plexSettings?: PlexSettings; timeout?: number; }) { const settings = getSettings(); let settingsPlex: PlexSettings | undefined; plexSettings ? (settingsPlex = plexSettings) : (settingsPlex = getSettings().plex); // Store the token for later use this.plexToken = plexToken; this.plexClient = new NodePlexAPI({ hostname: settingsPlex.ip, port: settingsPlex.port, https: settingsPlex.useSsl, timeout: timeout, token: plexToken, authenticator: { authenticate: ( _plexApi, cb: (err?: string, token?: string) => void ) => { if (!plexToken) { return cb('Plex Token not found!'); } cb(undefined, plexToken); }, }, options: { identifier: settings.clientId, product: 'Agregarr', deviceName: 'Agregarr', platform: 'Agregarr', }, }); // Initialize hub manager this.hubManager = new PlexHubManager(this); // Initialize smart collection manager this.smartCollectionManager = new PlexSmartCollectionManager(this); // Initialize poster manager this.posterManager = new PlexPosterManager(this); } public async getStatus() { return await this.plexClient.query('/'); } /** * Check if a collection is a smart collection * @param collectionRatingKey The rating key of the collection to check * @returns true if the collection is smart, false otherwise */ private async isSmartCollection( collectionRatingKey: string ): Promise { try { const metadata = await this.getCollectionMetadata(collectionRatingKey); if (!metadata) { return false; } // Smart collections have smart="1" attribute in Plex API return metadata.smart === '1'; } catch (error) { logger.warn( `Failed to check if collection ${collectionRatingKey} is smart`, { label: 'Plex API', error: error instanceof Error ? error.message : String(error), } ); return false; } } public async getLibraries(): Promise { const startTime = Date.now(); try { const response = await this.plexClient.query( '/library/sections' ); // Only log if response time is unusually high (> 500ms) or if it fails const responseTime = Date.now() - startTime; if (responseTime > 500) { logger.warn('Slow Plex libraries fetch detected', { label: 'Plex API', libraryCount: response.MediaContainer.Directory?.length || 0, responseTime, }); } return response.MediaContainer.Directory; } catch (error) { logger.error('Failed to fetch Plex libraries', { label: 'Plex API', error: error instanceof Error ? error.message : String(error), responseTime: Date.now() - startTime, }); throw error; } } public async syncLibraries(): Promise { const settings = getSettings(); try { const libraries = await this.getLibraries(); const newLibraries: Library[] = libraries .filter( (library) => library.type === 'movie' || library.type === 'show' ) .filter((library) => library.agent !== 'com.plexapp.agents.none') .map((library) => { const existing = settings.plex.libraries.find( (l) => l.key === library.key && l.name === library.title ); return { key: library.key, name: library.title, type: library.type, lastScan: existing?.lastScan, }; }); settings.plex.libraries = newLibraries; settings.save(); } catch (e) { logger.error('Failed to sync Plex libraries - keeping existing data', { label: 'Plex API', message: e.message, }); throw e; } } public async getLibraryContents( id: string, { offset = 0, size = 50 }: { offset?: number; size?: number } = {} ): Promise<{ totalSize: number; items: PlexLibraryItem[] }> { const uri = `/library/sections/${id}/all?includeGuids=1`; const headers = { 'X-Plex-Container-Start': `${offset}`, 'X-Plex-Container-Size': `${size}`, }; const response = await this.plexClient.query({ uri, extraHeaders: headers, }); const totalSize = response.MediaContainer.totalSize; return { totalSize, items: response.MediaContainer.Metadata ?? [], }; } public async getMetadata( key: string, options: { includeChildren?: boolean } = {} ): Promise { const response = await this.plexClient.query( `/library/metadata/${key}${ options.includeChildren ? '?includeChildren=1' : '' }` ); return response.MediaContainer.Metadata[0]; } public async getChildrenMetadata(key: string): Promise { const response = await this.plexClient.query( `/library/metadata/${key}/children` ); return response.MediaContainer.Metadata; } /** * Find a specific episode within a show * @param showRatingKey - The show's Plex rating key * @param seasonNumber - Season number (1-based) * @param episodeNumber - Episode number within the season (1-based) * @returns The episode's PlexLibraryItem or null if not found */ public async getShowEpisode( showRatingKey: string, seasonNumber: number, episodeNumber: number ): Promise { try { // First get all seasons for the show const seasons = await this.getChildrenMetadata(showRatingKey); // Find the specific season const season = seasons.find( (s) => s.type === 'season' && s.index === seasonNumber ); if (!season) { logger.debug( `Season ${seasonNumber} not found for show ${showRatingKey}`, { label: 'PlexAPI', } ); return null; } // Get all episodes for this season const episodes = await this.getChildrenMetadata(season.ratingKey); // Find the specific episode const episode = episodes.find( (e) => e.type === 'episode' && e.index === episodeNumber ); if (!episode) { logger.debug( `Episode ${episodeNumber} not found in season ${seasonNumber} for show ${showRatingKey}`, { label: 'PlexAPI' } ); return null; } return episode as PlexLibraryItem; } catch (error) { logger.error( `Failed to find episode S${seasonNumber}E${episodeNumber} for show ${showRatingKey}`, { label: 'PlexAPI', error: error instanceof Error ? error.message : String(error), } ); return null; } } /** * Get all episodes from a show * @param showRatingKey - The show's Plex rating key * @returns Array of all episodes in the show with their metadata including TMDB GUIDs */ public async getAllEpisodesFromShow( showRatingKey: string ): Promise { try { // Get all seasons for the show const seasons = await this.getChildrenMetadata(showRatingKey); const allEpisodes: PlexLibraryItem[] = []; // Get episodes from each season for (const season of seasons) { if (season.type === 'season') { const episodes = await this.getChildrenMetadata(season.ratingKey); // For each episode, get full metadata including GUIDs for (const episode of episodes.filter( (ep) => ep.type === 'episode' )) { try { // Get full episode metadata with GUIDs const fullEpisodeMetadata = await this.getMetadata( episode.ratingKey ); allEpisodes.push(fullEpisodeMetadata as PlexLibraryItem); } catch (error) { logger.warn( `Failed to get full metadata for episode ${episode.ratingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : String(error), } ); // Fallback to basic episode metadata (without GUIDs) allEpisodes.push(episode as PlexLibraryItem); } } } } return allEpisodes; } catch (error) { logger.warn(`Failed to get episodes for show ${showRatingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : String(error), }); return []; } } public async getRecentlyAdded( id: string, options: { addedAt: number } = { addedAt: Date.now() - 1000 * 60 * 60, }, mediaType: 'movie' | 'show' ): Promise { const response = await this.plexClient.query({ uri: `/library/sections/${id}/all?type=${ mediaType === 'show' ? '2' : '1' }&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`, extraHeaders: { 'X-Plex-Container-Start': `0`, 'X-Plex-Container-Size': `500`, }, }); return response.MediaContainer.Metadata; } public async getAllCollections(): Promise { logger.debug('Fetching all Plex collections', { label: 'Plex API' }); const startTime = Date.now(); const allCollections: PlexCollection[] = []; try { const allLibraries = await this.getLibraries(); // Filter to only movie and show libraries - we don't manage music, photo, or other library types const libraries = allLibraries.filter( (library) => library.type === 'movie' || library.type === 'show' ); logger.debug('Processing collections across libraries', { label: 'Plex API', libraryCount: libraries.length, }); for (const library of libraries) { try { const response = await this.plexClient.query({ uri: `/library/sections/${library.key}/collections`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); const collections = response.MediaContainer?.Metadata || []; for (const collection of collections) { const detailedCollection = await this.getCollectionMetadata( collection.ratingKey ); const labels = detailedCollection?.labels || []; const enhancedCollection: PlexCollection = { ...collection, libraryKey: library.key, libraryName: library.title, labels, titleSort: detailedCollection?.titleSort, }; allCollections.push(enhancedCollection); } } catch (error) { logger.warn( `Failed to get collections for library ${library.title}`, { label: 'Plex API', error, } ); } } } catch (error) { logger.error('Error getting all collections.', { label: 'Plex API', error, }); } // Collections fetched from Plex logger.debug('All collections fetched successfully', { label: 'Plex API', collectionCount: allCollections.length, responseTime: Date.now() - startTime, }); // Return collections in Plex's natural order - don't force addedAt sorting return allCollections; } public async getCollectionMetadata( ratingKey: string ): Promise { try { const response = await this.plexClient.query<{ MediaContainer: { Metadata: PlexCollectionMetadata[] }; }>(`/library/metadata/${ratingKey}`); const collection = response.MediaContainer?.Metadata?.[0]; if (!collection) { // Collection not found - this is different from an API error logger.debug(`Collection ${ratingKey} not found`, { label: 'Plex API', }); return null; } const labels = this.parseLabelsFromCollection(collection); return { ...collection, labels, }; } catch (error) { logger.error(`Failed to get collection metadata for ${ratingKey}`, { label: 'Plex API', error, }); // Throw error to distinguish from "collection not found" throw new Error( `API error getting collection metadata: ${ error instanceof Error ? error.message : 'Unknown error' }` ); } } /** * Safely get collection metadata with error handling * Returns null for both "not found" and "API error" cases, but logs appropriately */ public async getCollectionMetadataSafe( ratingKey: string ): Promise { try { return await this.getCollectionMetadata(ratingKey); } catch (error) { // API error already logged in getCollectionMetadata return null; } } private parseLabelsFromCollection(collection: PlexCollection): string[] { // Handle multiple possible label structures from Plex API if (Array.isArray(collection.Label)) { return collection.Label.map((label) => label.tag).filter( (tag): tag is string => typeof tag === 'string' ); } // Fallback: check if labels are already processed and stored in the labels property if (Array.isArray(collection.labels)) { return collection.labels; } return []; } public async getItemsByRatingKeys( ratingKeys: string[] ): Promise { if (ratingKeys.length === 0) { return []; } try { // Use bulk fetching with comma-separated rating keys (like Python PlexAPI) const ratingKeysParam = ratingKeys.join(','); const response = await this.plexClient.query( `/library/metadata/${ratingKeysParam}` ); const items = response.MediaContainer?.Metadata || []; // CRITICAL: Preserve the original order from ratingKeys array // Plex returns items in alphabetical order, but we need chronological request order const orderedItems: PlexCollectionItem[] = []; const missingRatingKeys: string[] = []; for (const ratingKey of ratingKeys) { const item = items.find( (item: PlexCollectionItem) => item.ratingKey === ratingKey ); if (item) { orderedItems.push(item); } else { missingRatingKeys.push(ratingKey); } } if (missingRatingKeys.length > 0) { logger.warn( `${missingRatingKeys.length}/${ratingKeys.length} items could not be found in Plex library.`, { label: 'Plex API', totalRequested: ratingKeys.length, totalFound: items.length, missingRatingKeys: missingRatingKeys, } ); } return orderedItems; } catch (error) { // If bulk fetch fails, fall back to individual requests logger.warn('Bulk fetch failed, falling back to individual requests.', { label: 'Plex API', }); const items: PlexCollectionItem[] = []; const failedRatingKeys: string[] = []; for (const ratingKey of ratingKeys) { try { const response = await this.plexClient.query( `/library/metadata/${ratingKey}` ); if (response.MediaContainer?.Metadata?.[0]) { items.push(response.MediaContainer.Metadata[0]); } else { failedRatingKeys.push(ratingKey); } } catch { failedRatingKeys.push(ratingKey); } } if (failedRatingKeys.length > 0) { logger.warn( `${failedRatingKeys.length}/${ratingKeys.length} items could not be found in Plex library.`, { label: 'Plex API', totalRequested: ratingKeys.length, totalFound: items.length, missingRatingKeys: failedRatingKeys, } ); } return items; } } public async getCollectionByName( name: string, libraryKey: string ): Promise { try { const response = await this.plexClient.query({ uri: `/library/sections/${libraryKey}/collections`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); const collections = response.MediaContainer?.Metadata || []; const foundCollection = collections.find( (collection: PlexCollection) => collection.title === name ) || null; if (foundCollection) { const detailedCollection = await this.getCollectionMetadata( foundCollection.ratingKey ); const labels = detailedCollection?.labels || []; return { ...foundCollection, libraryKey, labels, }; } return null; } catch (error) { logger.error(`Error getting collection by name "${name}"`, { label: 'Plex API', error, }); return null; } } public async createEmptyCollection( title: string, libraryKey: string, mediaType: 'movie' | 'tv' = 'movie', containsEpisodes = false ): Promise { try { // Use correct type parameter: 1 for movies, 2 for TV shows, 4 for episodes let typeParam: number; if (containsEpisodes) { typeParam = 4; // Episode collections } else { typeParam = mediaType === 'tv' ? 2 : 1; // TV show or movie collections } const createUrl = `/library/collections?type=${typeParam}&title=${encodeURIComponent( title )}&smart=0§ionId=${libraryKey}`; const result = await this.safePostQuery(createUrl); let collectionRatingKey: string | null = null; if (result && typeof result === 'object' && 'MediaContainer' in result) { const resultObj = result as { MediaContainer?: { Metadata?: PlexCollection[] }; }; if (resultObj.MediaContainer?.Metadata?.[0]) { collectionRatingKey = resultObj.MediaContainer.Metadata[0].ratingKey; } } return collectionRatingKey; } catch (error) { logger.error(`Error creating collection "${title}"`, { label: 'Plex API', title, libraryKey, mediaType, typeParam: mediaType === 'tv' ? 2 : 1, createUrl: `/library/collections?type=${ mediaType === 'tv' ? 2 : 1 }&title=${encodeURIComponent(title)}&smart=0§ionId=${libraryKey}`, error: error instanceof Error ? { message: error.message, stack: error.stack, name: error.name, } : error, }); return null; } } public async addItemsToCollection( collectionRatingKey: string, items: PlexCollectionItem[] ): Promise { if (items.length === 0) { return; } // PROTECTION: Never add items to smart collections - they are auto-populated by Plex const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.error( `PROTECTION: Attempted to add items to smart collection ${collectionRatingKey}. This could corrupt the Plex database!`, { label: 'Plex API', collectionRatingKey, itemCount: items.length, protection: 'SMART_COLLECTION_BLOCK', } ); throw new Error( `Cannot add items to smart collection ${collectionRatingKey}. Smart collections are auto-populated by Plex.` ); } const machineId = getSettings().plex.machineId; // Check if any items are episodes by querying their metadata let hasEpisodes = false; if (items.length <= 5) { // Only check first few items for performance try { const itemChecks = await Promise.all( items.slice(0, 3).map(async (item) => { try { const response = await this.plexClient.query( `/library/metadata/${item.ratingKey}` ); const metadata = response.MediaContainer?.Metadata?.[0]; return metadata?.type === 'episode'; } catch { return false; } }) ); hasEpisodes = itemChecks.some((isEpisode) => isEpisode); } catch { // If we can't check, assume no episodes hasEpisodes = false; } } try { // Use bulk addition with comma-separated rating keys const ratingKeys = items.map((item) => item.ratingKey).join(','); const uriParam = `server://${machineId}/com.plexapp.plugins.library/library/metadata/${ratingKeys}`; let addUrl = `/library/collections/${collectionRatingKey}/items?uri=${encodeURIComponent( uriParam )}`; // Add type=4 parameter for episode collections if (hasEpisodes) { addUrl += '&type=4'; } await this.safePutQuery(addUrl); } catch (error) { // If bulk addition fails, fall back to individual addition logger.warn( 'Bulk item addition failed, falling back to individual addition.', { label: 'Plex API', collectionRatingKey, } ); for (const item of items) { try { const uriParam = `server://${machineId}/com.plexapp.plugins.library/library/metadata/${item.ratingKey}`; let addUrl = `/library/collections/${collectionRatingKey}/items?uri=${encodeURIComponent( uriParam )}`; // Add type=4 parameter for episode collections (reuse the hasEpisodes check from above) if (hasEpisodes) { addUrl += '&type=4'; } await this.safePutQuery(addUrl); } catch (itemError) { const errorMessage = itemError instanceof Error ? itemError.message : 'Unknown error'; logger.warn( `Failed to add item "${item.title || 'Unknown'}" to collection.`, { label: 'Plex API', itemRatingKey: item.ratingKey, collectionRatingKey, error: errorMessage, } ); } } } } /** * Get items in a collection */ public async getCollectionItems( collectionRatingKey: string ): Promise { try { const response = await this.plexClient.query({ uri: `/library/collections/${collectionRatingKey}/children`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); const items = response.MediaContainer?.Metadata || []; return items.map((item: PlexCollectionItem) => item.ratingKey); } catch (error) { logger.error( `Error getting items from collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); return []; } } /** * Get full metadata for items in a collection, including Guid array for TMDB IDs * This is specifically for collections (smart or regular) - NOT for regular metadata items */ public async getCollectionItemsWithMetadata( collectionRatingKey: string ): Promise { try { const response = await this.plexClient.query({ uri: `/library/collections/${collectionRatingKey}/children?includeGuids=1`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); return response.MediaContainer?.Metadata || []; } catch (error) { logger.error( `Error getting metadata from collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); return []; } } public async removeItemsFromCollection( collectionRatingKey: string ): Promise { // PROTECTION: Never remove items from smart collections - they are auto-populated by Plex const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.error( `PROTECTION: Attempted to remove items from smart collection ${collectionRatingKey}. This could corrupt the Plex database!`, { label: 'Plex API', collectionRatingKey, protection: 'SMART_COLLECTION_BLOCK', } ); throw new Error( `Cannot remove items from smart collection ${collectionRatingKey}. Smart collections are auto-populated by Plex.` ); } try { const response = await this.plexClient.query({ uri: `/library/collections/${collectionRatingKey}/children`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); const items = response.MediaContainer?.Metadata || []; if (items.length === 0) { return; } for (const item of items) { const removeUrl = `/library/collections/${collectionRatingKey}/items/${item.ratingKey}`; try { await this.safeDeleteQuery(removeUrl); } catch (error) { const errorMessage = (error as Error).message; if (!errorMessage.includes('404')) { logger.warn( `Failed to remove item ${item.ratingKey} from collection`, { label: 'Plex API', error: errorMessage, } ); } } } } catch (error) { logger.error( `Error removing items from collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); throw error; } } public async addLabelToCollection( collectionRatingKey: string, label: string ): Promise { return this.addLabelToCollectionWithRetry(collectionRatingKey, label, 3); } /** * Add label to collection with retry logic and verification */ private async addLabelToCollectionWithRetry( collectionRatingKey: string, label: string, maxRetries: number ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Get current collection metadata to preserve existing labels // Use strict version to distinguish API errors from "not found" const collectionMeta = await this.getCollectionMetadata( collectionRatingKey ); if (!collectionMeta) { throw new Error(`Collection ${collectionRatingKey} not found`); } // Check if label already exists first (case-insensitive comparison since Plex auto-formats labels) const existingLabels = collectionMeta.labels || []; const labelExistsIndex = existingLabels.findIndex( (existingLabel) => existingLabel.toLowerCase() === label.toLowerCase() ); if (labelExistsIndex !== -1) { return true; // Early return - no changes needed } // Clean existing Agregarr labels while preserving user's custom labels // Only remove OTHER Agregarr labels, not the one we're trying to add const { cleanAgregarrCollectionLabels } = await import( '@server/lib/collections/core/CollectionUtilities' ); const preservedLabels = cleanAgregarrCollectionLabels( existingLabels, label ); // Combine preserved labels with new Agregarr label const allLabels = [...preservedLabels, label]; // Build params with all labels to preserve existing ones const params: Record = { 'label.locked': 1, }; // Add each label as a separate parameter allLabels.forEach((labelTag, index) => { params[`label[${index}].tag.tag`] = labelTag; }); const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); const editUrl = `/library/metadata/${collectionRatingKey}?${queryString}`; await this.safePutQuery(editUrl); // Verify the label was actually added (with a small delay for Plex API) await new Promise((resolve) => setTimeout(resolve, 500)); // Allow Plex time to index the label const updatedMeta = await this.getCollectionMetadata( collectionRatingKey ); if ( !updatedMeta || !updatedMeta.labels?.some( (existingLabel) => existingLabel.toLowerCase() === label.toLowerCase() ) ) { // Don't fail immediately - Plex might need more time to index labels logger.warn( `Label verification delayed for collection ${collectionRatingKey} - label "${label}" not immediately visible`, { label: 'Plex API', foundLabels: updatedMeta?.labels || [], expectedLabel: label, } ); // Give Plex more time and try once more await new Promise((resolve) => setTimeout(resolve, 1500)); const finalMeta = await this.getCollectionMetadata( collectionRatingKey ); if ( !finalMeta || !finalMeta.labels?.some( (existingLabel) => existingLabel.toLowerCase() === label.toLowerCase() ) ) { throw new Error( `Label verification failed - label "${label}" not found on collection after multiple attempts. Found labels: ${JSON.stringify( finalMeta?.labels || [] )}` ); } } return true; } catch (error) { logger.warn( `Attempt ${attempt}/${maxRetries} failed to add label "${label}" to collection ${collectionRatingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : 'Unknown error', attempt, maxRetries, } ); if (attempt === maxRetries) { logger.error( `Failed to add label "${label}" to collection ${collectionRatingKey} after ${maxRetries} attempts`, { label: 'Plex API', error, } ); return false; } // Wait before retrying (exponential backoff) await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); } } return false; } public async updateCollectionTitle( collectionRatingKey: string, title: string ): Promise { try { const params = { 'title.value': title, }; const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); const editUrl = `/library/metadata/${collectionRatingKey}?${queryString}`; await this.safePutQuery(editUrl); } catch (error) { logger.error( `Error updating title for collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); } } /** * Update collection mode (visibility of individual items) * @param collectionRatingKey - Collection rating key * @param mode - Collection mode: -1 = inherit library default, 0 = library default, 1 = hide items show collection, 2 = show collection and items, 3 = hide collection show items */ public async updateCollectionMode( collectionRatingKey: string, mode: -1 | 0 | 1 | 2 | 3 ): Promise { try { // Plex uses /prefs endpoint with collectionMode query parameter const prefsUrl = `/library/metadata/${collectionRatingKey}/prefs?collectionMode=${mode}`; await this.safePutQuery(prefsUrl); logger.debug( `Updated collection mode to ${mode} for collection ${collectionRatingKey}`, { label: 'Plex API', mode, collectionRatingKey, } ); } catch (error) { logger.error( `Error updating collection mode for collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); } } /** * Update the title of an individual item (movie, show, episode) */ public async updateItemTitle( ratingKey: string, title: string ): Promise { try { const params = { 'title.value': title, 'title.locked': '1', // Lock to prevent Plex from overwriting }; const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); const editUrl = `/library/metadata/${ratingKey}?${queryString}`; await this.safePutQuery(editUrl); logger.debug('Updated item title', { label: 'Plex API', ratingKey, title, }); } catch (error) { logger.error(`Error updating title for item ${ratingKey}`, { label: 'Plex API', error, }); throw error; } } /** * Add a label to an individual item (movie, show, episode) */ public async addLabelToItem(ratingKey: string, label: string): Promise { try { // Get current item metadata to preserve existing labels const metadata = await this.getMetadata(ratingKey); // Get existing labels const existingLabels: string[] = []; if (metadata && 'Label' in metadata) { const labels = metadata.Label as { tag: string }[] | undefined; if (labels && Array.isArray(labels)) { existingLabels.push(...labels.map((l) => l.tag)); } } // Check if label already exists if (existingLabels.includes(label)) { logger.debug('Label already exists on item', { label: 'Plex API', ratingKey, labelTag: label, }); return; } // Build params with all labels (existing + new) const allLabels = [...existingLabels, label]; const params: Record = {}; allLabels.forEach((labelTag, index) => { params[`label[${index}].tag.tag`] = labelTag; }); const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); const editUrl = `/library/metadata/${ratingKey}?${queryString}`; await this.safePutQuery(editUrl); logger.debug('Added label to item', { label: 'Plex API', ratingKey, labelTag: label, }); } catch (error) { logger.error(`Error adding label to item ${ratingKey}`, { label: 'Plex API', error, }); throw error; } } /** * Remove a label from an individual item (movie, show, episode) */ public async removeLabelFromItem( ratingKey: string, label: string ): Promise { try { // Get current item metadata to check existing labels const metadata = await this.getMetadata(ratingKey); // Get existing labels const existingLabels: string[] = []; if (metadata && 'Label' in metadata) { const labels = metadata.Label as { tag: string }[] | undefined; if (labels && Array.isArray(labels)) { existingLabels.push(...labels.map((l) => l.tag)); } } // Check if label exists (case-insensitive) const labelIndex = existingLabels.findIndex( (existingLabel) => existingLabel.toLowerCase() === label.toLowerCase() ); if (labelIndex === -1) { logger.debug('Label does not exist on item, nothing to remove', { label: 'Plex API', ratingKey, labelTag: label, }); return; } // Remove the label from the array const updatedLabels = existingLabels.filter( (_, index) => index !== labelIndex ); // Build params with remaining labels const params: Record = {}; updatedLabels.forEach((labelTag, index) => { params[`label[${index}].tag.tag`] = labelTag; }); // If no labels remain, we still need to send the request to clear all labels const queryString = updatedLabels.length > 0 ? Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&') : 'label[0].tag.tag-='; const editUrl = `/library/metadata/${ratingKey}?${queryString}`; await this.safePutQuery(editUrl); logger.debug('Removed label from item', { label: 'Plex API', ratingKey, labelTag: label, remainingLabels: updatedLabels, }); } catch (error) { logger.error(`Error removing label from item ${ratingKey}`, { label: 'Plex API', error, }); throw error; } } /** * Get all items in a library that have a specific label * @param libraryKey - Library section key * @param labelName - Label to search for * @returns Array of rating keys for items with the label */ public async getItemsWithLabel( libraryKey: string, labelName: string ): Promise { try { const response = await this.plexClient.query<{ MediaContainer?: { Metadata?: { ratingKey: string }[] }; }>({ uri: `/library/sections/${libraryKey}/all?label=${encodeURIComponent( labelName )}`, extraHeaders: { 'X-Plex-Container-Size': `0`, }, }); const items = response.MediaContainer?.Metadata || []; return items.map((item) => item.ratingKey); } catch (error) { logger.error( `Error getting items with label "${labelName}" in library ${libraryKey}`, { label: 'Plex API', libraryKey, labelName, error, } ); return []; } } public async updateCollectionSortTitle( collectionRatingKey: string, sortTitle: string ): Promise { try { const params = { type: 18, id: collectionRatingKey, 'titleSort.value': sortTitle, 'titleSort.locked': 1, }; const queryString = Object.entries(params) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join('&'); const editUrl = `/library/metadata/${collectionRatingKey}?${queryString}`; await this.safePutQuery(editUrl); } catch (error) { logger.error( `Error updating sort title for collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); } } public async updateCollectionContentSort( collectionRatingKey: string, sortType: 'release' | 'alpha' | 'custom' = 'custom' ): Promise { try { // Map sort types to Plex integer values (from Python PlexAPI reverse engineering) const sortValues = { release: 0, // Order by release dates alpha: 1, // Order alphabetically custom: 2, // Custom collection order (preserves add order) }; // Use the correct endpoint discovered from Python PlexAPI debug output: // PUT /library/collections/{ratingKey}/prefs?collectionSort=2 const editUrl = `/library/collections/${collectionRatingKey}/prefs?collectionSort=${sortValues[sortType]}`; await this.safePutQuery(editUrl); } catch (error) { logger.error( `Error updating content sort for collection ${collectionRatingKey}`, { label: 'Plex API', error, } ); throw error; } } public async moveItemInCollection( collectionRatingKey: string, itemRatingKey: string, afterItemRatingKey: string ): Promise { // PROTECTION: Never move items in smart collections - they have their own ordering const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.debug( `PROTECTION: Attempted to move item in smart collection ${collectionRatingKey}. Skipping move for smart collection.`, { label: 'Plex API', collectionRatingKey, itemRatingKey, protection: 'SMART_COLLECTION_SKIP', } ); return false; // Just return false for smart collections, don't throw error } try { // Use the exact API endpoint discovered from Python PlexAPI debug output: // PUT /library/collections/{collectionRatingKey}/items/{itemRatingKey}/move?after={afterItemRatingKey} const moveUrl = `/library/collections/${collectionRatingKey}/items/${itemRatingKey}/move?after=${afterItemRatingKey}`; await this.safePutQuery(moveUrl); return true; } catch (error) { // Silently fail - this is not critical for functionality return false; } } public async arrangeCollectionItemsInOrder( collectionRatingKey: string, orderedItems: PlexCollectionItem[] ): Promise { if (orderedItems.length <= 1) { return; // No need to arrange single item or empty collections } // PROTECTION: Never arrange items in smart collections - they have their own ordering const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.warn( `PROTECTION: Attempted to arrange items in smart collection ${collectionRatingKey}. Skipping arrangement for smart collection.`, { label: 'Plex API', collectionRatingKey, itemCount: orderedItems.length, protection: 'SMART_COLLECTION_SKIP', } ); return; // Just skip arrangement for smart collections, don't throw error } // Fetch current order once const currentOrder = await this.getCollectionItems(collectionRatingKey); const desiredOrder = orderedItems.map((item) => item.ratingKey); // Early return optimization: Check if already in correct order if ( currentOrder.length === desiredOrder.length && currentOrder.every( (ratingKey, index) => ratingKey === desiredOrder[index] ) ) { logger.debug( `Collection ${collectionRatingKey} is already in correct order. Skipping reordering.`, { label: 'Plex API', collectionRatingKey, itemCount: orderedItems.length, } ); return; } let moveCount = 0; let failCount = 0; // Selective reordering: Only move items that are out of position for (let i = 0; i < desiredOrder.length; i++) { if (currentOrder[i] !== desiredOrder[i]) { const itemToMove = desiredOrder[i]; let success = false; if (i === 0) { // Special case: position 0 - move without 'after' parameter try { const moveUrl = `/library/collections/${collectionRatingKey}/items/${itemToMove}/move`; await this.safePutQuery(moveUrl); success = true; } catch (error) { success = false; } } else { // Normal case: move after the previous item const afterItem = desiredOrder[i - 1]; success = await this.moveItemInCollection( collectionRatingKey, itemToMove, afterItem ); } if (success) { moveCount++; // Update in-memory tracking: remove from old position and insert at new position const oldIndex = currentOrder.indexOf(itemToMove); currentOrder.splice(oldIndex, 1); currentOrder.splice(i, 0, itemToMove); } else { failCount++; } } } if (moveCount > 0) { logger.debug( `Selectively moved ${moveCount} items in collection ${collectionRatingKey}`, { label: 'Plex API', collectionRatingKey, totalItems: orderedItems.length, movedItems: moveCount, } ); } if (failCount > 0) { logger.warn( `Failed to arrange ${failCount} items in collection ${collectionRatingKey}`, { label: 'Plex API', } ); } } public async updateCollectionVisibility( collectionRatingKey: string, recommended: boolean, home: boolean, shared: boolean ): Promise { try { // Get collection metadata to determine library section const collectionMeta = await this.plexClient.query( `/library/metadata/${collectionRatingKey}` ); const librarySectionID = collectionMeta.MediaContainer?.Metadata?.[0]?.librarySectionID; if (!librarySectionID) { throw new Error( `Could not determine library section ID for collection ${collectionRatingKey}` ); } // Initialize hub for collection visibility management const hubInitUrl = `/hubs/sections/${librarySectionID}/manage?metadataItemId=${collectionRatingKey}`; await this.safePostQuery(hubInitUrl); // Update visibility settings const hubIdentifier = `custom.collection.${librarySectionID}.${collectionRatingKey}`; const params = new URLSearchParams({ promotedToRecommended: recommended ? '1' : '0', promotedToOwnHome: home ? '1' : '0', promotedToSharedHome: shared ? '1' : '0', }); const putUrl = `/hubs/sections/${librarySectionID}/manage/${hubIdentifier}?${params.toString()}`; await this.safePutQuery(putUrl); } catch (error) { logger.error( `Error updating visibility for collection ${collectionRatingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : String(error), collectionRatingKey, recommended, home, shared, } ); } } /** * Remove specific items from a collection (incremental update) */ public async removeSpecificItemsFromCollection( collectionRatingKey: string, itemsToRemove: string[] ): Promise<{ successful: number; failed: number }> { let successful = 0; let failed = 0; // PROTECTION: Never remove items from smart collections - they are auto-populated by Plex const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.error( `PROTECTION: Attempted to remove specific items from smart collection ${collectionRatingKey}. This could corrupt the Plex database!`, { label: 'Plex API', collectionRatingKey, itemCount: itemsToRemove.length, protection: 'SMART_COLLECTION_BLOCK', } ); throw new Error( `Cannot remove items from smart collection ${collectionRatingKey}. Smart collections are auto-populated by Plex.` ); } for (const ratingKey of itemsToRemove) { const removeUrl = `/library/collections/${collectionRatingKey}/items/${ratingKey}`; try { await this.safeDeleteQuery(removeUrl); successful++; } catch (error) { failed++; const errorMessage = (error as Error).message; if (!errorMessage.includes('404')) { logger.warn( `Failed to remove item ${ratingKey} from collection ${collectionRatingKey}`, { label: 'Plex API', error: errorMessage, } ); } } } return { successful, failed }; } /** * Incrementally update collection contents (preserve collection, update items only) * This replaces the delete/recreate approach with smart add/remove/reorder */ public async updateCollectionContents( collectionRatingKey: string, desiredItems: PlexCollectionItem[] ): Promise<{ added: number; removed: number; reordered: boolean; errors: string[]; }> { const errors: string[] = []; let added = 0; let removed = 0; let reordered = false; // PROTECTION: Never update smart collections - they are auto-populated by Plex const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.error( `PROTECTION: Attempted to update contents of smart collection ${collectionRatingKey}. This could corrupt the Plex database!`, { label: 'Plex API', collectionRatingKey, itemCount: desiredItems.length, protection: 'SMART_COLLECTION_BLOCK', } ); throw new Error( `Cannot update smart collection ${collectionRatingKey}. Smart collections are auto-populated by Plex.` ); } try { // Get current collection contents (returns array of rating keys) const currentRatingKeys = await this.getCollectionItems( collectionRatingKey ); const currentRatingKeysSet = new Set(currentRatingKeys); const desiredRatingKeysSet = new Set( desiredItems.map((item) => item.ratingKey) ); // Calculate what needs to be added and removed const toAdd = desiredItems.filter( (item) => !currentRatingKeysSet.has(item.ratingKey) ); const toRemoveKeys = currentRatingKeys.filter( (ratingKey) => !desiredRatingKeysSet.has(ratingKey) ); // Remove items that shouldn't be in the collection if (toRemoveKeys.length > 0) { const removeResult = await this.removeSpecificItemsFromCollection( collectionRatingKey, toRemoveKeys ); removed = removeResult.successful; if (removeResult.failed > 0) { errors.push(`Failed to remove ${removeResult.failed} items`); } } // Add new items to the collection if (toAdd.length > 0) { const addResult = await this.addSpecificItemsToCollection( collectionRatingKey, toAdd.map((item) => item.ratingKey) ); added = addResult.successful; if (addResult.failed > 0) { errors.push(`Failed to add ${addResult.failed} items`); } } // Always reorder items to match desired order for consistency if (desiredItems.length > 0) { try { await this.arrangeCollectionItemsInOrder( collectionRatingKey, desiredItems ); reordered = true; } catch (error) { errors.push( `Failed to reorder collection: ${(error as Error).message}` ); } } return { added, removed, reordered, errors }; } catch (error) { errors.push(`Collection update failed: ${(error as Error).message}`); return { added: 0, removed: 0, reordered: false, errors }; } } /** * Add specific items to a collection (incremental update) */ public async addSpecificItemsToCollection( collectionRatingKey: string, itemsToAdd: string[] ): Promise<{ successful: number; failed: number }> { let successful = 0; let failed = 0; // PROTECTION: Never add items to smart collections - they are auto-populated by Plex const isSmart = await this.isSmartCollection(collectionRatingKey); if (isSmart) { logger.error( `PROTECTION: Attempted to add specific items to smart collection ${collectionRatingKey}. This could corrupt the Plex database!`, { label: 'Plex API', collectionRatingKey, itemCount: itemsToAdd.length, protection: 'SMART_COLLECTION_BLOCK', } ); throw new Error( `Cannot add items to smart collection ${collectionRatingKey}. Smart collections are auto-populated by Plex.` ); } // Validate all items exist before attempting to add them const validItems = await this.getItemsByRatingKeys(itemsToAdd); const validRatingKeys = validItems.map((item) => item.ratingKey); if (validRatingKeys.length === 0) { logger.warn( `No valid items to add to collection ${collectionRatingKey}`, { label: 'Plex API', requestedItems: itemsToAdd.length, } ); return { successful: 0, failed: itemsToAdd.length }; } // Check which items are already in the collection to avoid duplicate additions const currentItems = await this.getCollectionItems(collectionRatingKey); const currentItemsSet = new Set(currentItems); const itemsToActuallyAdd = validRatingKeys.filter( (key) => !currentItemsSet.has(key) ); // Check what type of items these are and which library they belong to const itemTypes = await Promise.all( itemsToActuallyAdd.slice(0, 4).map(async (ratingKey) => { try { const response = await this.plexClient.query( `/library/metadata/${ratingKey}` ); const item = response.MediaContainer?.Metadata?.[0]; return { ratingKey, type: item?.type, title: item?.title, librarySectionID: item?.librarySectionID, }; } catch { return { ratingKey, type: 'unknown', title: 'unknown', librarySectionID: 'unknown', }; } }) ); // Also get the collection's library info let collectionLibrary = 'unknown'; try { const collResponse = await this.plexClient.query( `/library/collections/${collectionRatingKey}` ); collectionLibrary = collResponse.MediaContainer?.Metadata?.[0]?.librarySectionID || 'unknown'; } catch (error) { logger.warn( `Failed to get collection library info for ${collectionRatingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : error, } ); } const itemLibraries = Array.from( new Set(itemTypes.map((item) => item.librarySectionID)) ); const libraryMismatch = itemLibraries.length > 0 && collectionLibrary !== 'unknown' && !itemLibraries.includes(Number(collectionLibrary)); if (libraryMismatch) { logger.error( `LIBRARY MISMATCH DETECTED: Collection ${collectionRatingKey} is in library ${collectionLibrary} but items are in libraries [${itemLibraries.join( ',' )}]`, { label: 'Plex API', collectionLibrary, itemLibraries, collectionRatingKey, } ); } logger.debug(`Collection ${collectionRatingKey} item analysis`, { label: 'Plex API', requestedItems: itemsToAdd.length, validItems: validRatingKeys.length, currentItems: currentItems.length, itemsToAdd: itemsToActuallyAdd.length, newItems: itemsToActuallyAdd, itemTypes: itemTypes, collectionLibrary: collectionLibrary, }); if (itemsToActuallyAdd.length === 0) { logger.info(`All items already in collection ${collectionRatingKey}`, { label: 'Plex API', requestedItems: itemsToAdd.length, validItems: validRatingKeys.length, }); return { successful: validRatingKeys.length, failed: itemsToAdd.length - validRatingKeys.length, }; } // Add all items at once - no need for batching const machineId = getSettings().plex.machineId; const uri = `server://${machineId}/com.plexapp.plugins.library/library/metadata/${itemsToActuallyAdd.join( ',' )}`; const addUrl = `/library/collections/${collectionRatingKey}/items?uri=${encodeURIComponent( uri )}`; // Check if we're adding episodes - if so, we might need special handling const hasEpisodes = itemTypes.some((item) => item.type === 'episode'); try { if (hasEpisodes) { // For episodes, try adding the type=4 parameter const episodeAddUrl = `${addUrl}&type=4`; await this.safePutQuery(episodeAddUrl); } else { await this.safePutQuery(addUrl); } successful = itemsToActuallyAdd.length; } catch (error) { failed = itemsToActuallyAdd.length; logger.error( `Error adding ${itemsToActuallyAdd.length} items to collection ${collectionRatingKey}`, { label: 'Plex API', error: error instanceof Error ? error.message : error, itemCount: itemsToActuallyAdd.length, uri: uri.length > 200 ? uri.substring(0, 200) + '...' : uri, } ); } // Account for items that were filtered out or already in collection const alreadyInCollection = validRatingKeys.length - itemsToActuallyAdd.length; const invalidItems = itemsToAdd.length - validRatingKeys.length; return { successful: successful + alreadyInCollection, failed: failed + invalidItems, }; } public async deleteCollection(collectionRatingKey: string): Promise { try { await this.safeDeleteQuery(`/library/collections/${collectionRatingKey}`); } catch (error) { logger.error(`Error deleting collection ${collectionRatingKey}.`, { label: 'Plex API', error, }); throw error; } } /** * Trigger a Plex library scan/refresh * @param libraryId - The library section ID to scan */ public async scanLibrary(libraryId: string): Promise { try { logger.debug('Triggering Plex library scan', { label: 'Plex API', libraryId, }); await this.plexClient.query(`/library/sections/${libraryId}/refresh`); logger.info('Plex library scan triggered', { label: 'Plex API', libraryId, }); } catch (error) { logger.error('Failed to trigger Plex library scan', { label: 'Plex API', libraryId, error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Empty trash for a Plex library section * Removes items that Plex has detected as missing/unavailable * @param libraryId - The library section ID to empty trash for */ public async emptyTrash(libraryId: string): Promise { try { logger.debug('Emptying Plex library trash', { label: 'Plex API', libraryId, }); await this.safePutQuery(`/library/sections/${libraryId}/emptyTrash`); logger.info('Plex library trash emptied', { label: 'Plex API', libraryId, }); } catch (error) { logger.error('Failed to empty Plex library trash', { label: 'Plex API', libraryId, error: error instanceof Error ? error.message : String(error), }); throw error; } } // PLEX.TV METHODS - Delegated to PlexTvAPI /** * Get Plex user display name for a given Plex user ID * Delegates to PlexTvAPI */ public async getPlexUserTitle(userPlexId: string): Promise { if (!this.plexToken) { return null; } const PlexTvAPI = (await import('./plextv')).default; const plexTvApi = new PlexTvAPI(this.plexToken); return plexTvApi.getPlexUserTitle(userPlexId); } // HUB MANAGEMENT METHODS - Delegated to PlexHubManager /** * Get all hubs for a specific library section * Returns both built-in hubs (Recently Added, etc.) and custom collections */ public async getLibraryHubs(sectionId: string): Promise { return this.hubManager.getLibraryHubs(sectionId); } /** * Get hub management interface for a library section * This endpoint provides the drag-and-drop hub ordering interface */ public async getHubManagement( sectionId: string ): Promise { return this.hubManager.getHubManagement(sectionId); } /** * Move a hub to a new position in the library home screen */ public async moveHub( sectionId: string, hubId: string, afterHubId?: string ): Promise { return this.hubManager.moveHub(sectionId, hubId, afterHubId); } /** * Get current collection visibility settings */ public async getCollectionVisibility( collectionRatingKey: string ): Promise { return this.hubManager.getCollectionVisibility(collectionRatingKey); } /** * Update hub visibility settings */ public async updateHubVisibility( sectionId: string, hubId: string, visibility: { promotedToRecommended?: boolean; promotedToOwnHome?: boolean; promotedToSharedHome?: boolean; } ): Promise { return this.hubManager.updateHubVisibility(sectionId, hubId, visibility); } /** * Get all available hubs across all library sections */ public async getAllLibraryHubs(): Promise<{ [sectionId: string]: unknown }> { return this.hubManager.getAllLibraryHubs(); } /** * Reorder multiple hubs in a library section */ public async reorderHubs( sectionId: string, desiredOrder: string[], positionedItemsCount?: number, libraryType?: 'movie' | 'show', syncCounter?: number ): Promise { return this.hubManager.reorderHubs( sectionId, desiredOrder, positionedItemsCount, libraryType, syncCounter ); } /** * Reset all hub management for a library section */ public async resetLibraryHubManagement(sectionId: string): Promise { return this.hubManager.resetLibraryHubManagement(sectionId); } /** * Delete a hub item from a library section */ public async deleteHubItem(sectionId: string, hubId: string): Promise { return this.hubManager.deleteHubItem(sectionId, hubId); } /** * Promote a collection to hub management */ public async promoteCollectionToHub( collectionRatingKey: string, libraryId: string ): Promise { return this.hubManager.promoteCollectionToHub( collectionRatingKey, libraryId ); } // SMART COLLECTION METHODS - Delegated to PlexSmartCollectionManager /** * Create a label-based smart collection for unwatched items * New approach: labels items directly, no base collection needed */ public async createLabelBasedSmartCollection( title: string, libraryKey: string, labelName: string, mediaType: 'movie' | 'tv' = 'movie', sortOption?: string, agregarrLabel?: string, maxItems?: number ): Promise { return this.smartCollectionManager.createLabelBasedSmartCollection( title, libraryKey, labelName, mediaType, sortOption, agregarrLabel, maxItems ); } /** * Set collection filtering to be based on the current user viewing the content */ public async setCollectionUserFilter( collectionRatingKey: string ): Promise { return this.smartCollectionManager.setCollectionUserFilter( collectionRatingKey ); } /** * Update a label-based smart collection's URI (including sort parameters) */ public async updateLabelBasedSmartCollectionUri( smartCollectionRatingKey: string, libraryKey: string, labelName: string, mediaType: 'movie' | 'tv' = 'movie', sortOption?: string, maxItems?: number ): Promise { return this.smartCollectionManager.updateLabelBasedSmartCollectionUri( smartCollectionRatingKey, libraryKey, labelName, mediaType, sortOption, maxItems ); } /** * Update an existing filtered hub smart collection's URI */ public async updateFilteredHubUri( smartCollectionRatingKey: string, libraryKey: string, mediaType: 'movie' | 'tv', subtype: | 'recently_added' | 'recently_released' | 'recently_released_episodes', maxItems?: number ): Promise { return this.smartCollectionManager.updateFilteredHubUri( smartCollectionRatingKey, libraryKey, mediaType, subtype, maxItems ); } /** * Delete a smart collection */ public async deleteSmartCollection( smartCollectionRatingKey: string ): Promise { return this.smartCollectionManager.deleteSmartCollection( smartCollectionRatingKey ); } /** * Create a smart collection filtered by director name */ public async createDirectorCollection( title: string, libraryKey: string, mediaType: 'movie' | 'tv', directorName: string, limit?: number ): Promise { return this.smartCollectionManager.createDirectorCollection( title, libraryKey, mediaType, directorName, limit ); } /** * Create a smart collection filtered by actor name */ public async createActorCollection( title: string, libraryKey: string, mediaType: 'movie' | 'tv', actorName: string, limit?: number ): Promise { return this.smartCollectionManager.createActorCollection( title, libraryKey, mediaType, actorName, limit ); } // POSTER MANAGEMENT METHODS - Delegated to PlexPosterManager /** * Get all available posters for a Plex item */ public async getAvailablePosters(ratingKey: string): Promise { return this.posterManager.getAvailablePosters(ratingKey); } /** * Upload a poster from a URL */ public async uploadPosterFromUrl( ratingKey: string, url: string ): Promise { return this.posterManager.uploadPosterFromUrl(ratingKey, url); } /** * Upload a poster from a local file path */ public async uploadPosterFromFile( ratingKey: string, filepath: string ): Promise { return this.posterManager.uploadPosterFromFile(ratingKey, filepath); } /** * Select an existing poster for an item */ public async selectPoster( ratingKey: string, posterRatingKey: string ): Promise { return this.posterManager.selectPoster(ratingKey, posterRatingKey); } /** * Lock the poster for an item (prevents auto-updates) */ public async lockPoster(ratingKey: string): Promise { return this.posterManager.lockPoster(ratingKey); } /** * Unlock the poster for an item (allows auto-updates) */ public async unlockPoster(ratingKey: string): Promise { return this.posterManager.unlockPoster(ratingKey); } /** * Get current poster URL for a Plex item */ public async getCurrentPosterUrl(ratingKey: string): Promise { return this.posterManager.getCurrentPosterUrl(ratingKey); } /** * Get current art/wallpaper URL for a Plex item */ public async getCurrentArtUrl(ratingKey: string): Promise { return this.posterManager.getCurrentArtUrl(ratingKey); } /** * Get current theme URL for a Plex item */ public async getCurrentThemeUrl(ratingKey: string): Promise { return this.posterManager.getCurrentThemeUrl(ratingKey); } /** * Combined method for uploading and setting a poster (backwards compatibility) */ public async updateCollectionPoster( collectionRatingKey: string, posterPath: string ): Promise { return this.posterManager.updateCollectionPoster( collectionRatingKey, posterPath ); } /** * Upload wallpaper/art from a local file path */ public async uploadArtFromFile( ratingKey: string, filepath: string ): Promise { return this.posterManager.uploadArtFromFile(ratingKey, filepath); } /** * Lock the art for an item (prevents auto-updates) */ public async lockArt(ratingKey: string): Promise { return this.posterManager.lockArt(ratingKey); } /** * Upload theme music from a local file path */ public async uploadThemeFromFile( ratingKey: string, filepath: string ): Promise { return this.posterManager.uploadThemeFromFile(ratingKey, filepath); } /** * Lock the theme for an item (prevents auto-updates) */ public async lockTheme(ratingKey: string): Promise { return this.posterManager.lockTheme(ratingKey); } /** * Update collection summary/description */ public async updateSummary( ratingKey: string, summary: string ): Promise { return this.posterManager.updateSummary(ratingKey, summary); } /** * Get top directors from a library section with their item counts * Excludes placeholder items using the same query filters as smart collections */ public async getLibraryDirectors( libraryId: string, limit?: number ): Promise<{ name: string; count: number }[]> { try { logger.debug(`Fetching directors from library ${libraryId}`, { label: 'Plex API', libraryId, limit, }); // Fetch library metadata to determine media type const libraries = await this.getLibraries(); const library = libraries.find((lib) => lib.key === libraryId); const mediaType = library?.type === 'show' ? 'tv' : 'movie'; const type = mediaType === 'movie' ? 1 : 2; // Build query with placeholder exclusions (same as smart collections) let queryUri: string; if (mediaType === 'tv') { const titleFilter = encodeURIComponent('Trailer (Placeholder)'); queryUri = `/library/sections/${libraryId}/all?type=${type}&episode.title!=${titleFilter}`; } else { const labelFilter = encodeURIComponent('trailer-placeholder'); queryUri = `/library/sections/${libraryId}/all?type=${type}&label!=${labelFilter}`; } const response = await this.plexClient.query<{ MediaContainer: { totalSize: number; Metadata?: { Director?: { tag: string }[]; }[]; }; }>({ uri: queryUri, extraHeaders: { 'X-Plex-Container-Size': '0', // Get all items }, }); const items = response.MediaContainer.Metadata || []; const directorCounts = new Map(); for (const item of items) { if (item.Director && Array.isArray(item.Director)) { for (const director of item.Director) { if (director.tag) { const currentCount = directorCounts.get(director.tag) || 0; directorCounts.set(director.tag, currentCount + 1); } } } } let directors = Array.from(directorCounts.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); if (limit && limit > 0) { directors = directors.slice(0, limit); } logger.info( `Found ${directorCounts.size} unique directors in library ${libraryId}`, { label: 'Plex API', libraryId, totalDirectors: directorCounts.size, returned: directors.length, topDirectors: directors .slice(0, 5) .map((d) => `${d.name} (${d.count})`), } ); return directors; } catch (error) { logger.error(`Failed to fetch directors from library ${libraryId}`, { label: 'Plex API', libraryId, error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Get top actors from a library section with their item counts * Excludes placeholder items using the same query filters as smart collections */ public async getLibraryActors( libraryId: string, limit?: number ): Promise<{ name: string; count: number }[]> { try { logger.debug(`Fetching actors from library ${libraryId}`, { label: 'Plex API', libraryId, limit, }); // Fetch library metadata to determine media type const libraries = await this.getLibraries(); const library = libraries.find((lib) => lib.key === libraryId); const mediaType = library?.type === 'show' ? 'tv' : 'movie'; const type = mediaType === 'movie' ? 1 : 2; // Build query with placeholder exclusions (same as smart collections) let queryUri: string; if (mediaType === 'tv') { const titleFilter = encodeURIComponent('Trailer (Placeholder)'); queryUri = `/library/sections/${libraryId}/all?type=${type}&episode.title!=${titleFilter}`; } else { const labelFilter = encodeURIComponent('trailer-placeholder'); queryUri = `/library/sections/${libraryId}/all?type=${type}&label!=${labelFilter}`; } const response = await this.plexClient.query<{ MediaContainer: { totalSize: number; Metadata?: { Role?: { tag: string }[]; }[]; }; }>({ uri: queryUri, extraHeaders: { 'X-Plex-Container-Size': '0', // Get all items }, }); const items = response.MediaContainer.Metadata || []; const actorCounts = new Map(); for (const item of items) { const roles = (item as { Role?: { tag?: string }[] }).Role; if (roles && Array.isArray(roles)) { // Only consider the first few actors per item to avoid noisy long casts for (const role of roles.slice(0, 5)) { if (role.tag) { const currentCount = actorCounts.get(role.tag) || 0; actorCounts.set(role.tag, currentCount + 1); } } } } let actors = Array.from(actorCounts.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); if (limit && limit > 0) { actors = actors.slice(0, limit); } logger.info( `Found ${actorCounts.size} unique actors in library ${libraryId}`, { label: 'Plex API', libraryId, totalActors: actorCounts.size, returned: actors.length, topActors: actors.slice(0, 5).map((d) => `${d.name} (${d.count})`), } ); return actors; } catch (error) { logger.error(`Failed to fetch actors from library ${libraryId}`, { label: 'Plex API', libraryId, error: error instanceof Error ? error.message : String(error), }); throw error; } } /** * Get library items for a specific director (movies or TV) */ public async getItemsByDirector( libraryId: string, directorName: string, mediaType: 'movie' | 'tv', limit?: number ): Promise { const type = mediaType === 'movie' ? 1 : 2; const directorFilter = encodeURIComponent(directorName); const filterParams = mediaType === 'tv' ? `episode.title!=${encodeURIComponent('Trailer (Placeholder)')}` : `label!=${encodeURIComponent('trailer-placeholder')}`; let uri = `/library/sections/${libraryId}/all?type=${type}&director=${directorFilter}&${filterParams}&includeGuids=1`; if (limit && limit > 0) { uri += `&limit=${limit}`; } try { const response = await this.plexClient.query<{ MediaContainer: { Metadata?: PlexLibraryItem[] }; }>({ uri, extraHeaders: limit ? { 'X-Plex-Container-Size': `${limit}`, } : undefined, }); return response.MediaContainer.Metadata || []; } catch (error) { logger.error( `Failed to fetch items for director "${directorName}" in library ${libraryId}`, { label: 'Plex API', directorName, libraryId, mediaType, limit, error: error instanceof Error ? error.message : String(error), } ); throw error; } } /** * Get library items for a specific actor (movies or TV) */ public async getItemsByActor( libraryId: string, actorName: string, mediaType: 'movie' | 'tv', limit?: number ): Promise { const type = mediaType === 'movie' ? 1 : 2; const actorFilter = encodeURIComponent(actorName); const filterParams = mediaType === 'tv' ? `episode.title!=${encodeURIComponent('Trailer (Placeholder)')}` : `label!=${encodeURIComponent('trailer-placeholder')}`; let uri = `/library/sections/${libraryId}/all?type=${type}&actor=${actorFilter}&${filterParams}&includeGuids=1`; if (limit && limit > 0) { uri += `&limit=${limit}`; } try { const response = await this.plexClient.query<{ MediaContainer: { Metadata?: PlexLibraryItem[] }; }>({ uri, extraHeaders: limit ? { 'X-Plex-Container-Size': `${limit}`, } : undefined, }); return response.MediaContainer.Metadata || []; } catch (error) { logger.error( `Failed to fetch items for actor "${actorName}" in library ${libraryId}`, { label: 'Plex API', actorName, libraryId, mediaType, limit, error: error instanceof Error ? error.message : String(error), } ); throw error; } } } export default PlexAPI;