856 lines
28 KiB
TypeScript

import OverseerrAPI, {
type OverseerrMediaRequest,
} from '@server/api/overseerr';
import TheMovieDb from '@server/api/themoviedb';
import { getRepository } from '@server/datasource';
import { MissingItemRequest } from '@server/entity/MissingItemRequest';
import type { User } from '@server/entity/User';
import type {
AutoRequestResult,
MissingItem,
} from '@server/lib/collections/core/types';
import type { CollectionConfig } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { missingItemFilterService } from './MissingItemFilterService';
import type { ServiceType } from './ServiceUserManager';
import { ServiceUserManager } from './ServiceUserManager';
import { syncCacheService } from './SyncCacheService';
/**
* Shared auto-request service for all collection sync implementations
*
* Handles the common auto-request functionality that can be reused across
* different collection sources (Trakt, TMDB, IMDb, etc.)
*/
export class AutoRequestService {
private serviceUserManager: ServiceUserManager;
private missingItemRepository = getRepository(MissingItemRequest);
private tmdbAPI: TheMovieDb;
constructor() {
this.serviceUserManager = new ServiceUserManager();
this.tmdbAPI = new TheMovieDb();
}
/**
* Get Overseerr API client with current settings
*/
private getOverseerrAPI(): OverseerrAPI {
const settings = getSettings();
const overseerrSettings = settings.overseerr;
if (!overseerrSettings?.hostname || !overseerrSettings?.apiKey) {
throw new Error('Overseerr API settings not configured');
}
// Create fresh client with current settings
return new OverseerrAPI(overseerrSettings);
}
/**
* Process auto-requests for missing items from any collection source
*
* @param missingItems - Items that are missing from Plex
* @param config - Collection configuration with auto-request settings
* @param source - Source type for logging and service user selection
* @returns Promise with auto-request results
*/
public async processAutoRequests(
missingItems: MissingItem[],
config: CollectionConfig,
source:
| 'trakt'
| 'tmdb'
| 'imdb'
| 'letterboxd'
| 'anilist'
| 'myanimelist'
| 'mdblist'
| 'networks'
| 'originals'
| 'multi-source'
): Promise<AutoRequestResult> {
// Only proceed if auto-request is enabled
if (!config.searchMissingMovies && !config.searchMissingTV) {
return {
autoApproved: 0,
manualApproval: 0,
alreadyRequested: 0,
skipped: 0,
total: 0,
};
}
// Filter items using shared filtering service
const filterResult = await missingItemFilterService.filterMissingItems(
missingItems,
config,
'Auto Request Service'
);
if (filterResult.filteredItems.length === 0) {
return {
autoApproved: 0,
manualApproval: 0,
alreadyRequested: 0,
skipped: 0,
total: 0,
};
}
try {
// Note: We no longer create separate users upfront.
// Users are created dynamically with appropriate permissions per request.
// OPTIMIZATION: Use cached requests if available, otherwise fetch fresh data
let allRequestsResults: OverseerrMediaRequest[];
if (syncCacheService.getIsInitialized()) {
allRequestsResults = syncCacheService.getOverseerrRequests();
logger.debug(
`Using cached Overseerr requests (${allRequestsResults.length} requests)`,
{
label: 'Auto Request Service',
cachedRequests: allRequestsResults.length,
}
);
} else {
// Fallback to fresh API call if cache not available
const overseerrAPI = this.getOverseerrAPI();
const allRequests = await overseerrAPI.getRequests({ take: 2000 });
allRequestsResults = allRequests.results;
logger.debug(
`Cache not available, fetched fresh Overseerr requests (${allRequestsResults.length} requests)`,
{
label: 'Auto Request Service',
freshRequests: allRequestsResults.length,
}
);
}
let autoApprovedRequests = 0;
let manualApprovalRequests = 0;
let alreadyRequestedCount = 0;
let skippedRequests = 0;
const maxSeasons =
config.maxSeasonsToRequest !== undefined &&
config.maxSeasonsToRequest !== null
? Number(config.maxSeasonsToRequest)
: 0; // 0 = no limit
// Track declined items and season limits for summary logging
const previouslyDeclinedItems: string[] = [];
const tooManySeasons: string[] = [];
for (const item of filterResult.filteredItems) {
try {
// Check if request already exists using cached requests
const existingRequest = this.checkExistingRequestFromCache(
item.tmdbId,
item.mediaType,
allRequestsResults
);
if (existingRequest) {
alreadyRequestedCount++;
continue;
}
// Check season limit for ALL TV shows first (regardless of auto-approve setting)
// Only skip if maxSeasons is set (> 0)
if (item.mediaType === 'tv' && maxSeasons > 0) {
const seasonCount = await missingItemFilterService.getTvSeasonCount(
item.tmdbId
);
if (seasonCount > maxSeasons) {
// Track TV shows that exceed the season limit
tooManySeasons.push(item.title);
skippedRequests++;
continue;
}
}
// Determine if this request should be auto-approved
let autoApprove = false;
let requestType = 'manual-approval';
if (item.mediaType === 'movie' && config.autoApproveMovies) {
autoApprove = true;
requestType = 'auto-approved';
} else if (item.mediaType === 'tv' && config.autoApproveTV) {
// Auto-approve TV shows (season limit already checked above)
autoApprove = true;
requestType = 'auto-approved';
}
// Get service user with dynamic permissions based on auto-approve decision
const serviceUserToUse = await this.getServiceUserForRequest(
source as ServiceType,
config.name, // Use collection name as collection type
autoApprove
);
// For manual approval requests, check if this item was previously declined by this service user
if (!autoApprove) {
if (
this.wasPreviouslyDeclinedFromCache(
item.tmdbId,
item.mediaType,
serviceUserToUse,
allRequestsResults
)
) {
previouslyDeclinedItems.push(item.title);
skippedRequests++;
continue;
}
}
// Create the actual request via Overseerr API
const overseerrAPI = this.getOverseerrAPI();
// For TV shows, request seasons based on seasonsPerShowLimit and seasonGrabOrder
let seasons: number[] | 'all' | undefined;
if (item.mediaType === 'tv') {
const seasonsLimit = config.seasonsPerShowLimit;
const grabOrder = config.seasonGrabOrder || 'first'; // Default to 'first' for backwards compatibility
if (seasonsLimit && seasonsLimit > 0) {
// Use the new season selection helper
const selectedSeasons =
await missingItemFilterService.selectSeasonsToGrab(
item.tmdbId,
seasonsLimit,
grabOrder
);
seasons = selectedSeasons;
logger.debug(
`Selecting seasons for ${
item.title
} using ${grabOrder} mode: [${selectedSeasons.join(', ')}]`,
{
label: 'Auto Request Service',
collection: config.name,
mode: grabOrder,
limit: seasonsLimit,
}
);
} else {
// Use 'all' to request all available seasons
seasons = 'all';
}
}
const userIdToUse =
serviceUserToUse.externalOverseerrId || serviceUserToUse.id;
// Determine server/profile/root folder/tags based on media type and config overrides
let serverId: number | undefined;
let profileId: number | undefined;
let rootFolder: string | undefined;
let tags: string[] | undefined;
if (item.mediaType === 'movie') {
serverId = config.overseerrRadarrServerId;
profileId = config.overseerrRadarrProfileId;
rootFolder = config.overseerrRadarrRootFolder;
tags = config.overseerrRadarrTags?.map((id) => String(id));
} else if (item.mediaType === 'tv') {
serverId = config.overseerrSonarrServerId;
profileId = config.overseerrSonarrProfileId;
rootFolder = config.overseerrSonarrRootFolder;
tags = config.overseerrSonarrTags?.map((id) => String(id));
}
await overseerrAPI.createRequest({
mediaId: item.tmdbId,
mediaType: item.mediaType,
seasons,
is4k: false,
userId: userIdToUse,
serverId,
profileId,
rootFolder,
tags,
});
// Fetch poster from TMDB
let posterPath: string | undefined;
try {
posterPath = await this.fetchTmdbPoster(
item.tmdbId,
item.mediaType
);
} catch (error) {
logger.debug(`Failed to fetch poster for ${item.title}`, {
label: 'Auto Request Service',
tmdbId: item.tmdbId,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
// Track the missing item request
await this.trackMissingItemRequest({
tmdbId: item.tmdbId,
mediaType: item.mediaType,
title: item.title,
posterPath,
year: item.year,
collectionName: config.name,
collectionSource: source,
collectionSubtype: undefined, // Could be expanded later for granular tracking
requestService: 'overseerr',
requestMethod: autoApprove ? 'auto' : 'manual',
requestStatus: autoApprove ? 'approved' : 'pending',
requestedBy: serviceUserToUse,
requestedAt: new Date(),
});
if (requestType.includes('auto-approved')) {
autoApprovedRequests++;
} else {
manualApprovalRequests++;
}
logger.debug(
`Created ${requestType} request for ${item.mediaType}: ${item.title} (TMDB: ${item.tmdbId})`,
{
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
config: config.name,
}
);
} catch (error) {
logger.warn(
`Failed to create auto-request for ${item.title}: ${error}`,
{
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
}
);
}
}
// Log summary of declined items
if (previouslyDeclinedItems.length > 0) {
logger.info(`Items skipped due to previous decline`, {
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
collection: config.name,
count: previouslyDeclinedItems.length,
titles: previouslyDeclinedItems.slice(0, 10), // Limit to first 10 titles
...(previouslyDeclinedItems.length > 10 && {
additionalCount: previouslyDeclinedItems.length - 10,
}),
});
}
// Log summary of items with too many seasons
if (tooManySeasons.length > 0) {
logger.info(
`TV shows skipped due to exceeding ${maxSeasons} season limit`,
{
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
collection: config.name,
count: tooManySeasons.length,
titles: tooManySeasons.slice(0, 10), // Limit to first 10 titles
...(tooManySeasons.length > 10 && {
additionalCount: tooManySeasons.length - 10,
}),
}
);
}
// Log filtering summary (genres, countries, IMDb ratings)
missingItemFilterService.logFilteringSummary(
filterResult,
config,
source
);
// To maintain exact compatibility with original behavior, add filter counts to skipped
const totalSkipped =
skippedRequests +
filterResult.lowRatedItems.length +
filterResult.lowRatedRTItems.length +
filterResult.excludedGenreItems.length +
filterResult.excludedCountryItems.length +
filterResult.excludedLanguageItems.length +
filterResult.includedGenreItems.length +
filterResult.includedCountryItems.length +
filterResult.includedLanguageItems.length;
const totalRequests = autoApprovedRequests + manualApprovalRequests;
if (totalRequests > 0) {
logger.info(
`${
source.charAt(0).toUpperCase() + source.slice(1)
} collection auto-requests created for ${
config.name
}: ${autoApprovedRequests} auto-approved, ${manualApprovalRequests} manual approval${
totalSkipped > 0 ? `, ${totalSkipped} skipped` : ''
}`,
{
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
}
);
}
return {
autoApproved: autoApprovedRequests,
manualApproval: manualApprovalRequests,
alreadyRequested: alreadyRequestedCount,
skipped: totalSkipped,
total: missingItems.length - filterResult.yearFilteredItems.length,
};
} catch (error) {
logger.error(
`Failed to handle auto-requests for ${source} collection ${config.name}: ${error}`,
{
label: `${
source.charAt(0).toUpperCase() + source.slice(1)
} Collections`,
}
);
throw error;
}
}
/**
* Get service user for request based on auto-approve setting
*/
private async getServiceUserForRequest(
source: ServiceType,
collectionType: string | undefined,
autoApprove: boolean
): Promise<User> {
return this.serviceUserManager.getOrCreateServiceUserForRequest(
source,
collectionType,
autoApprove
);
}
/**
* Check if a request already exists for the given media (DEPRECATED - use cached version)
*/
private async checkExistingRequest(
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<boolean> {
try {
// OPTIMIZATION: Use cached requests if available
if (syncCacheService.getIsInitialized()) {
const cachedRequests = syncCacheService.getOverseerrRequests();
return this.checkExistingRequestFromCache(
tmdbId,
mediaType,
cachedRequests
);
}
// Fallback to fresh API call
const overseerrAPI = this.getOverseerrAPI();
const requests = await overseerrAPI.getRequests({ take: 1000 });
const existingRequest = requests.results.find(
(request) =>
request.media.tmdbId === tmdbId &&
request.type === mediaType &&
request.status !== 3 // 3 = DECLINED status in Overseerr
);
return !!existingRequest;
} catch (error) {
logger.warn(`Failed to check existing request for TMDB ID ${tmdbId}`, {
label: 'Auto Request Service',
error: error instanceof Error ? error.message : 'Unknown error',
});
return false;
}
}
/**
* Check if a request already exists for the given media using cached requests
* OPTIMIZED: No API calls, uses pre-fetched data
*/
private checkExistingRequestFromCache(
tmdbId: number,
mediaType: 'movie' | 'tv',
cachedRequests: OverseerrMediaRequest[]
): boolean {
const existingRequest = cachedRequests.find(
(request) =>
request.media.tmdbId === tmdbId &&
request.type === mediaType &&
request.status !== 3 // 3 = DECLINED status in Overseerr
);
return !!existingRequest;
}
/**
* Check if a request was previously declined by this specific service user (DEPRECATED - use cached version)
*/
private async wasPreviouslyDeclined(
tmdbId: number,
mediaType: 'movie' | 'tv',
serviceUser: User
): Promise<boolean> {
try {
// OPTIMIZATION: Use cached requests if available
if (syncCacheService.getIsInitialized()) {
const cachedRequests = syncCacheService.getOverseerrRequests();
return this.wasPreviouslyDeclinedFromCache(
tmdbId,
mediaType,
serviceUser,
cachedRequests
);
}
// Fallback to fresh API call
const overseerrAPI = this.getOverseerrAPI();
// Get requests by this service user (use external ID if available)
const requests = await overseerrAPI.getRequests({
requestedBy: serviceUser.externalOverseerrId || serviceUser.id,
take: 1000,
});
const existingDeclinedRequest = requests.results.find(
(request) =>
request.media.tmdbId === tmdbId &&
request.type === mediaType &&
request.status === 3 && // 3 = DECLINED status in Overseerr
!request.is4k
);
return !!existingDeclinedRequest;
} catch (error) {
logger.warn(`Failed to check declined status for TMDB ID ${tmdbId}`, {
label: 'Auto Request Service',
tmdbId,
mediaType,
serviceUserId: serviceUser.id,
serviceUserName: serviceUser.displayName,
error: error instanceof Error ? error.message : 'Unknown error',
});
return false; // If we can't check, allow the request
}
}
/**
* Check if a request was previously declined by this specific service user using cached requests
* OPTIMIZED: No API calls, uses pre-fetched data
*/
private wasPreviouslyDeclinedFromCache(
tmdbId: number,
mediaType: 'movie' | 'tv',
serviceUser: User,
cachedRequests: OverseerrMediaRequest[]
): boolean {
const serviceUserId = serviceUser.externalOverseerrId || serviceUser.id;
const existingDeclinedRequest = cachedRequests.find(
(request) =>
request.media.tmdbId === tmdbId &&
request.type === mediaType &&
request.status === 3 && // 3 = DECLINED status in Overseerr
!request.is4k &&
request.requestedBy?.id === serviceUserId
);
return !!existingDeclinedRequest;
}
/**
* Track a missing item request in the database
*/
private async trackMissingItemRequest(data: {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
posterPath?: string;
year?: number;
collectionName: string;
collectionSource: string;
collectionSubtype?: string;
requestService: string;
requestMethod: string;
requestStatus: 'pending' | 'approved' | 'declined' | 'available';
requestedBy: User;
requestedAt: Date;
}): Promise<void> {
try {
const missingItemRequest = new MissingItemRequest({
tmdbId: data.tmdbId,
mediaType: data.mediaType,
title: data.title,
posterPath: data.posterPath,
year: data.year,
collectionName: data.collectionName,
collectionSource: data.collectionSource,
collectionSubtype: data.collectionSubtype,
requestService: data.requestService,
requestMethod: data.requestMethod,
requestStatus: data.requestStatus,
requestedBy: data.requestedBy,
requestedById: data.requestedBy.id,
requestedAt: data.requestedAt,
});
await this.missingItemRepository.save(missingItemRequest);
logger.debug(
`Tracked missing item request: ${data.title} (${data.mediaType})`,
{
label: 'Missing Item Tracking',
tmdbId: data.tmdbId,
collection: data.collectionName,
source: data.collectionSource,
service: data.requestService,
method: data.requestMethod,
status: data.requestStatus,
}
);
} catch (error) {
logger.warn(`Failed to track missing item request for ${data.title}`, {
label: 'Missing Item Tracking',
tmdbId: data.tmdbId,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* Fetch poster path from TMDB
*/
private async fetchTmdbPoster(
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<string | undefined> {
try {
const tmdb = new (await import('@server/api/themoviedb')).default();
if (mediaType === 'movie') {
const movie = await tmdb.getMovie({ movieId: tmdbId });
return movie.poster_path || undefined;
} else {
const tvShow = await tmdb.getTvShow({ tvId: tmdbId });
return tvShow.poster_path || undefined;
}
} catch (error) {
logger.debug(`Failed to fetch TMDB poster for ${mediaType} ${tmdbId}`, {
label: 'Auto Request Service',
tmdbId,
mediaType,
error: error instanceof Error ? error.message : 'Unknown error',
});
return undefined;
}
}
/**
* Sync status of missing item requests with Overseerr
*/
public async syncMissingItemStatus(): Promise<void> {
try {
const repository = getRepository(MissingItemRequest);
// Get all non-final status requests from the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const activeRequests = await repository
.createQueryBuilder('missing_item')
.leftJoinAndSelect('missing_item.requestedBy', 'user')
.where('missing_item.requestStatus IN (:...statuses)', {
statuses: ['pending', 'approved', 'processing'],
})
.andWhere('missing_item.createdAt >= :date', { date: thirtyDaysAgo })
.getMany();
const overseerrAPI = this.getOverseerrAPI();
let updated = 0;
// OPTIMIZATION: Use cached requests if available, otherwise fetch fresh data
let allRequestsResults: OverseerrMediaRequest[];
if (syncCacheService.getIsInitialized()) {
allRequestsResults = syncCacheService.getOverseerrRequests();
logger.debug(
`Using cached Overseerr requests for status sync (${allRequestsResults.length} requests)`,
{
label: 'Missing Item Status Sync',
cachedRequests: allRequestsResults.length,
}
);
} else {
// Fallback to fresh API call if cache not available
const allRequests = await overseerrAPI.getRequests({ take: 2000 });
allRequestsResults = allRequests.results;
logger.debug(
`Cache not available for status sync, fetched fresh requests (${allRequestsResults.length} requests)`,
{
label: 'Missing Item Status Sync',
freshRequests: allRequestsResults.length,
}
);
}
for (const missingItem of activeRequests) {
try {
// Find matching request in the cached results instead of making individual API calls
const overseerrRequest = allRequestsResults.find(
(req) =>
req.media.tmdbId === missingItem.tmdbId &&
req.type === missingItem.mediaType &&
req.requestedBy?.id ===
(missingItem.requestedBy?.externalOverseerrId ||
missingItem.requestedBy?.id)
);
if (overseerrRequest) {
let newStatus:
| 'pending'
| 'approved'
| 'declined'
| 'available'
| 'processing'
| 'failed'
| 'partially_available' = 'pending';
// MediaRequestStatus enum values
switch (overseerrRequest.status) {
case 1: // PENDING
newStatus = 'pending';
break;
case 2: // APPROVED
newStatus = 'approved';
break;
case 3: // DECLINED
newStatus = 'declined';
break;
case 4: // FAILED
newStatus = 'failed';
break;
case 5: // COMPLETED
// For completed requests, check if media is actually available
newStatus = 'processing'; // Default to processing until we check media status
break;
default:
// Log unknown status for debugging
logger.warn(
`Unknown Overseerr request status: ${overseerrRequest.status}`,
{
label: 'Missing Item Status Sync',
tmdbId: missingItem.tmdbId,
requestId: overseerrRequest.id,
status: overseerrRequest.status,
}
);
newStatus = 'pending';
}
// For completed/approved requests, also check media availability status
if (newStatus === 'processing' || newStatus === 'approved') {
try {
const media = await overseerrAPI.getMediaByTmdbId(
missingItem.tmdbId
);
if (media) {
// MediaStatus enum values
switch (media.status) {
case 5: // AVAILABLE
newStatus = 'available';
break;
case 4: // PARTIALLY_AVAILABLE
newStatus = 'partially_available';
break;
case 3: // PROCESSING
newStatus = 'processing';
break;
case 2: // PENDING
newStatus =
newStatus === 'approved' ? 'approved' : 'pending';
break;
default:
// Keep the request status if media status is unknown
break;
}
}
} catch (error) {
// If we can't get media status, keep the request status
logger.debug(
`Could not get media status for ${missingItem.title}`,
{
label: 'Missing Item Status Sync',
tmdbId: missingItem.tmdbId,
error:
error instanceof Error ? error.message : 'Unknown error',
}
);
}
}
if (newStatus !== missingItem.requestStatus) {
await repository.update(missingItem.id, {
requestStatus: newStatus,
overseerrRequestId: overseerrRequest.id,
});
updated++;
logger.debug(
`Updated missing item status: ${missingItem.title} -> ${newStatus}`,
{
label: 'Missing Item Status Sync',
tmdbId: missingItem.tmdbId,
oldStatus: missingItem.requestStatus,
newStatus,
}
);
}
}
} catch (error) {
logger.warn(`Failed to sync status for ${missingItem.title}`, {
label: 'Missing Item Status Sync',
tmdbId: missingItem.tmdbId,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
if (updated > 0) {
logger.info(
`Missing item status sync completed: ${updated} items updated`,
{
label: 'Missing Item Status Sync',
}
);
}
} catch (error) {
logger.error(`Failed to sync missing item status: ${error}`, {
label: 'Missing Item Status Sync',
});
}
}
}
// Export singleton instance
export const autoRequestService = new AutoRequestService();