mirror of
https://github.com/agregarr/agregarr.git
synced 2026-03-30 00:02:40 +08:00
856 lines
28 KiB
TypeScript
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();
|