mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
The jellyfinMediaId(4k) properties were inferred as string | undefined, causing them to be set to undefined when assigning null. This prevented the media from being saved correctly to the SQLite database, as it doesn't accept undefined values. This resolves the availabilitySync job issue where the "play on" button wasn't being removed for all media server types. fix #668
1092 lines
34 KiB
TypeScript
1092 lines
34 KiB
TypeScript
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
|
import JellyfinAPI from '@server/api/jellyfin';
|
|
import type { PlexMetadata } from '@server/api/plexapi';
|
|
import PlexAPI from '@server/api/plexapi';
|
|
import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr';
|
|
import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr';
|
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
|
import { MediaServerType } from '@server/constants/server';
|
|
import { getRepository } from '@server/datasource';
|
|
import Media from '@server/entity/Media';
|
|
import MediaRequest from '@server/entity/MediaRequest';
|
|
import type Season from '@server/entity/Season';
|
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
|
import { User } from '@server/entity/User';
|
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
|
import { getSettings } from '@server/lib/settings';
|
|
import logger from '@server/logger';
|
|
|
|
class AvailabilitySync {
|
|
public running = false;
|
|
private plexClient: PlexAPI;
|
|
private plexSeasonsCache: Record<string, PlexMetadata[]>;
|
|
|
|
private jellyfinClient: JellyfinAPI;
|
|
private jellyfinSeasonsCache: Record<string, JellyfinLibraryItem[]>;
|
|
|
|
private sonarrSeasonsCache: Record<string, SonarrSeason[]>;
|
|
private radarrServers: RadarrSettings[];
|
|
private sonarrServers: SonarrSettings[];
|
|
|
|
async run() {
|
|
const settings = getSettings();
|
|
const mediaServerType = getSettings().main.mediaServerType;
|
|
this.running = true;
|
|
this.plexSeasonsCache = {};
|
|
this.jellyfinSeasonsCache = {};
|
|
this.sonarrSeasonsCache = {};
|
|
this.radarrServers = settings.radarr.filter((server) => server.syncEnabled);
|
|
this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled);
|
|
|
|
try {
|
|
logger.info(`Starting availability sync...`, {
|
|
label: 'AvailabilitySync',
|
|
});
|
|
const pageSize = 50;
|
|
|
|
const userRepository = getRepository(User);
|
|
|
|
// If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID
|
|
|
|
let admin = null;
|
|
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
admin = await userRepository.findOne({
|
|
select: { id: true, plexToken: true },
|
|
where: { id: 1 },
|
|
});
|
|
} else if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
admin = await userRepository.findOne({
|
|
where: { id: 1 },
|
|
select: [
|
|
'id',
|
|
'jellyfinAuthToken',
|
|
'jellyfinUserId',
|
|
'jellyfinDeviceId',
|
|
],
|
|
order: { id: 'ASC' },
|
|
});
|
|
}
|
|
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
if (admin && admin.plexToken) {
|
|
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
|
|
} else {
|
|
logger.error('Plex admin is not configured.');
|
|
}
|
|
} else if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
if (admin) {
|
|
this.jellyfinClient = new JellyfinAPI(
|
|
settings.jellyfin.hostname ?? '',
|
|
admin.jellyfinAuthToken,
|
|
admin.jellyfinDeviceId
|
|
);
|
|
|
|
this.jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
|
} else {
|
|
logger.error('Jellyfin admin is not configured.');
|
|
}
|
|
} else {
|
|
logger.error('An admin is not configured.');
|
|
}
|
|
|
|
for await (const media of this.loadAvailableMediaPaginated(pageSize)) {
|
|
if (!this.running) {
|
|
throw new Error('Job aborted');
|
|
}
|
|
|
|
// Check plex, radarr, and sonarr for that specific media and
|
|
// if unavailable, then we change the status accordingly.
|
|
// If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version
|
|
if (media.mediaType === 'movie') {
|
|
let movieExists = false;
|
|
let movieExists4k = false;
|
|
|
|
// if (mediaServerType === MediaServerType.PLEX) {
|
|
// await this.mediaExistsInPlex(media, false);
|
|
// } else if (
|
|
// mediaServerType === MediaServerType.JELLYFIN ||
|
|
// mediaServerType === MediaServerType.EMBY
|
|
// ) {
|
|
// await this.mediaExistsInJellyfin(media, false);
|
|
// }
|
|
|
|
const existsInRadarr = await this.mediaExistsInRadarr(media, false);
|
|
const existsInRadarr4k = await this.mediaExistsInRadarr(media, true);
|
|
|
|
// plex
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
const { existsInPlex } = await this.mediaExistsInPlex(media, false);
|
|
const { existsInPlex: existsInPlex4k } =
|
|
await this.mediaExistsInPlex(media, true);
|
|
|
|
if (existsInPlex || existsInRadarr) {
|
|
movieExists = true;
|
|
logger.info(
|
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
|
|
if (existsInPlex4k || existsInRadarr4k) {
|
|
movieExists4k = true;
|
|
logger.info(
|
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
//jellyfin
|
|
if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
const { existsInJellyfin } = await this.mediaExistsInJellyfin(
|
|
media,
|
|
false
|
|
);
|
|
const { existsInJellyfin: existsInJellyfin4k } =
|
|
await this.mediaExistsInJellyfin(media, true);
|
|
|
|
if (existsInJellyfin || existsInRadarr) {
|
|
movieExists = true;
|
|
logger.info(
|
|
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
|
|
if (existsInJellyfin4k || existsInRadarr4k) {
|
|
movieExists4k = true;
|
|
logger.info(
|
|
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!movieExists && media.status === MediaStatus.AVAILABLE) {
|
|
await this.mediaUpdater(media, false, mediaServerType);
|
|
}
|
|
|
|
if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) {
|
|
await this.mediaUpdater(media, true, mediaServerType);
|
|
}
|
|
}
|
|
|
|
// If both versions still exist in plex, we still need
|
|
// to check through sonarr to verify season availability
|
|
if (media.mediaType === 'tv') {
|
|
let showExists = false;
|
|
let showExists4k = false;
|
|
|
|
//plex
|
|
|
|
const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } =
|
|
await this.mediaExistsInPlex(media, false);
|
|
const {
|
|
existsInPlex: existsInPlex4k,
|
|
seasonsMap: plexSeasonsMap4k = new Map(),
|
|
} = await this.mediaExistsInPlex(media, true);
|
|
|
|
//jellyfin
|
|
const {
|
|
existsInJellyfin,
|
|
seasonsMap: jellyfinSeasonsMap = new Map(),
|
|
} = await this.mediaExistsInJellyfin(media, false);
|
|
const {
|
|
existsInJellyfin: existsInJellyfin4k,
|
|
seasonsMap: jellyfinSeasonsMap4k = new Map(),
|
|
} = await this.mediaExistsInJellyfin(media, true);
|
|
|
|
const { existsInSonarr, seasonsMap: sonarrSeasonsMap } =
|
|
await this.mediaExistsInSonarr(media, false);
|
|
const {
|
|
existsInSonarr: existsInSonarr4k,
|
|
seasonsMap: sonarrSeasonsMap4k,
|
|
} = await this.mediaExistsInSonarr(media, true);
|
|
|
|
//plex
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
if (existsInPlex || existsInSonarr) {
|
|
showExists = true;
|
|
logger.info(
|
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
if (existsInPlex4k || existsInSonarr4k) {
|
|
showExists4k = true;
|
|
logger.info(
|
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
//jellyfin
|
|
if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
if (existsInJellyfin || existsInSonarr) {
|
|
showExists = true;
|
|
logger.info(
|
|
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
if (existsInJellyfin4k || existsInSonarr4k) {
|
|
showExists4k = true;
|
|
logger.info(
|
|
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
|
{
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Here we will create a final map that will cross compare
|
|
// with plex and sonarr. Filtered seasons will go through
|
|
// each season and assume the season does not exist. If Plex or
|
|
// Sonarr finds that season, we will change the final seasons value
|
|
// to true.
|
|
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
|
|
|
media.seasons
|
|
.filter(
|
|
(season) =>
|
|
season.status === MediaStatus.AVAILABLE ||
|
|
season.status === MediaStatus.PARTIALLY_AVAILABLE
|
|
)
|
|
.forEach((season) =>
|
|
filteredSeasonsMap.set(season.seasonNumber, false)
|
|
);
|
|
|
|
// non-4k
|
|
const finalSeasons: Map<number, boolean> = new Map();
|
|
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
plexSeasonsMap.forEach((value, key) => {
|
|
finalSeasons.set(key, value);
|
|
});
|
|
|
|
filteredSeasonsMap.forEach((value, key) => {
|
|
if (!finalSeasons.has(key)) {
|
|
finalSeasons.set(key, value);
|
|
}
|
|
});
|
|
|
|
sonarrSeasonsMap.forEach((value, key) => {
|
|
if (!finalSeasons.has(key)) {
|
|
finalSeasons.set(key, value);
|
|
}
|
|
});
|
|
} else if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
jellyfinSeasonsMap.forEach((value, key) => {
|
|
finalSeasons.set(key, value);
|
|
});
|
|
|
|
filteredSeasonsMap.forEach((value, key) => {
|
|
if (!finalSeasons.has(key)) {
|
|
finalSeasons.set(key, value);
|
|
}
|
|
});
|
|
|
|
sonarrSeasonsMap.forEach((value, key) => {
|
|
if (!finalSeasons.has(key)) {
|
|
finalSeasons.set(key, value);
|
|
}
|
|
});
|
|
}
|
|
|
|
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
|
|
|
|
media.seasons
|
|
.filter(
|
|
(season) =>
|
|
season.status4k === MediaStatus.AVAILABLE ||
|
|
season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
|
)
|
|
.forEach((season) =>
|
|
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
|
);
|
|
|
|
// 4k
|
|
const finalSeasons4k: Map<number, boolean> = new Map();
|
|
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
plexSeasonsMap4k.forEach((value, key) => {
|
|
finalSeasons4k.set(key, value);
|
|
});
|
|
|
|
filteredSeasonsMap4k.forEach((value, key) => {
|
|
if (!finalSeasons4k.has(key)) {
|
|
finalSeasons4k.set(key, value);
|
|
}
|
|
});
|
|
|
|
sonarrSeasonsMap4k.forEach((value, key) => {
|
|
if (!finalSeasons4k.has(key)) {
|
|
finalSeasons4k.set(key, value);
|
|
}
|
|
});
|
|
} else if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
jellyfinSeasonsMap4k.forEach((value, key) => {
|
|
finalSeasons4k.set(key, value);
|
|
});
|
|
|
|
filteredSeasonsMap4k.forEach((value, key) => {
|
|
if (!finalSeasons4k.has(key)) {
|
|
finalSeasons4k.set(key, value);
|
|
}
|
|
});
|
|
|
|
sonarrSeasonsMap4k.forEach((value, key) => {
|
|
if (!finalSeasons4k.has(key)) {
|
|
finalSeasons4k.set(key, value);
|
|
}
|
|
});
|
|
}
|
|
|
|
// TODO: Figure out how to run seasonUpdater for each season
|
|
|
|
if ([...finalSeasons.values()].includes(false)) {
|
|
await this.seasonUpdater(
|
|
media,
|
|
finalSeasons,
|
|
false,
|
|
mediaServerType
|
|
);
|
|
}
|
|
|
|
if ([...finalSeasons4k.values()].includes(false)) {
|
|
await this.seasonUpdater(
|
|
media,
|
|
finalSeasons4k,
|
|
true,
|
|
mediaServerType
|
|
);
|
|
}
|
|
|
|
if (
|
|
!showExists &&
|
|
(media.status === MediaStatus.AVAILABLE ||
|
|
media.status === MediaStatus.PARTIALLY_AVAILABLE)
|
|
) {
|
|
await this.mediaUpdater(media, false, mediaServerType);
|
|
}
|
|
|
|
if (
|
|
!showExists4k &&
|
|
(media.status4k === MediaStatus.AVAILABLE ||
|
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE)
|
|
) {
|
|
await this.mediaUpdater(media, true, mediaServerType);
|
|
}
|
|
}
|
|
}
|
|
} catch (ex) {
|
|
logger.error('Failed to complete availability sync.', {
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
});
|
|
} finally {
|
|
logger.info(`Availability sync complete.`, {
|
|
label: 'AvailabilitySync',
|
|
});
|
|
this.running = false;
|
|
}
|
|
}
|
|
|
|
public cancel() {
|
|
this.running = false;
|
|
}
|
|
|
|
private async *loadAvailableMediaPaginated(pageSize: number) {
|
|
let offset = 0;
|
|
const mediaRepository = getRepository(Media);
|
|
const whereOptions = [
|
|
{ status: MediaStatus.AVAILABLE },
|
|
{ status: MediaStatus.PARTIALLY_AVAILABLE },
|
|
{ status4k: MediaStatus.AVAILABLE },
|
|
{ status4k: MediaStatus.PARTIALLY_AVAILABLE },
|
|
];
|
|
|
|
let mediaPage: Media[];
|
|
|
|
do {
|
|
yield* (mediaPage = await mediaRepository.find({
|
|
where: whereOptions,
|
|
skip: offset,
|
|
take: pageSize,
|
|
}));
|
|
offset += pageSize;
|
|
} while (mediaPage.length > 0);
|
|
}
|
|
|
|
private findMediaStatus(
|
|
requests: MediaRequest[],
|
|
is4k: boolean
|
|
): MediaStatus {
|
|
const filteredRequests = requests.filter(
|
|
(request) => request.is4k === is4k
|
|
);
|
|
|
|
let mediaStatus: MediaStatus;
|
|
|
|
if (
|
|
filteredRequests.some(
|
|
(request) => request.status === MediaRequestStatus.APPROVED
|
|
)
|
|
) {
|
|
mediaStatus = MediaStatus.PROCESSING;
|
|
} else if (
|
|
filteredRequests.some(
|
|
(request) => request.status === MediaRequestStatus.PENDING
|
|
)
|
|
) {
|
|
mediaStatus = MediaStatus.PENDING;
|
|
} else {
|
|
mediaStatus = MediaStatus.UNKNOWN;
|
|
}
|
|
|
|
return mediaStatus;
|
|
}
|
|
|
|
private async mediaUpdater(
|
|
media: Media,
|
|
is4k: boolean,
|
|
mediaServerType: MediaServerType
|
|
): Promise<void> {
|
|
const mediaRepository = getRepository(Media);
|
|
const requestRepository = getRepository(MediaRequest);
|
|
|
|
try {
|
|
// Find all related requests only if
|
|
// the related media has an available status
|
|
const requests = await requestRepository
|
|
.createQueryBuilder('request')
|
|
.leftJoinAndSelect('request.media', 'media')
|
|
.where('(media.id = :id)', {
|
|
id: media.id,
|
|
})
|
|
.andWhere(
|
|
`(request.is4k = :is4k AND media.${
|
|
is4k ? 'status4k' : 'status'
|
|
} IN (:...mediaStatus))`,
|
|
{
|
|
mediaStatus: [
|
|
MediaStatus.AVAILABLE,
|
|
MediaStatus.PARTIALLY_AVAILABLE,
|
|
],
|
|
is4k: is4k,
|
|
}
|
|
)
|
|
.getMany();
|
|
|
|
// Check if a season is processing or pending to
|
|
// make sure we set the media to the correct status
|
|
let mediaStatus = MediaStatus.UNKNOWN;
|
|
|
|
if (media.mediaType === 'tv') {
|
|
mediaStatus = this.findMediaStatus(requests, is4k);
|
|
}
|
|
|
|
media[is4k ? 'status4k' : 'status'] = mediaStatus;
|
|
media[is4k ? 'serviceId4k' : 'serviceId'] =
|
|
mediaStatus === MediaStatus.PROCESSING
|
|
? media[is4k ? 'serviceId4k' : 'serviceId']
|
|
: null;
|
|
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
|
mediaStatus === MediaStatus.PROCESSING
|
|
? media[is4k ? 'externalServiceId4k' : 'externalServiceId']
|
|
: null;
|
|
media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
|
mediaStatus === MediaStatus.PROCESSING
|
|
? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']
|
|
: null;
|
|
if (mediaServerType === MediaServerType.PLEX) {
|
|
media[is4k ? 'ratingKey4k' : 'ratingKey'] =
|
|
mediaStatus === MediaStatus.PROCESSING
|
|
? media[is4k ? 'ratingKey4k' : 'ratingKey']
|
|
: null;
|
|
} else if (
|
|
mediaServerType === MediaServerType.JELLYFIN ||
|
|
mediaServerType === MediaServerType.EMBY
|
|
) {
|
|
media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
|
mediaStatus === MediaStatus.PROCESSING
|
|
? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId']
|
|
: null;
|
|
}
|
|
logger.info(
|
|
`The ${is4k ? '4K' : 'non-4K'} ${
|
|
media.mediaType === 'movie' ? 'movie' : 'show'
|
|
} [TMDB ID ${media.tmdbId}] was not found in any ${
|
|
media.mediaType === 'movie' ? 'Radarr' : 'Sonarr'
|
|
} and ${
|
|
mediaServerType === MediaServerType.PLEX
|
|
? 'plex'
|
|
: mediaServerType === MediaServerType.JELLYFIN
|
|
? 'jellyfin'
|
|
: 'emby'
|
|
} instance. Status will be changed to unknown.`,
|
|
{ label: 'AvailabilitySync' }
|
|
);
|
|
|
|
await mediaRepository.save({ media, ...media });
|
|
|
|
// Only delete media request if type is movie.
|
|
// Type tv request deletion is handled
|
|
// in the season request entity
|
|
if (requests.length > 0 && media.mediaType === 'movie') {
|
|
await requestRepository.remove(requests);
|
|
}
|
|
} catch (ex) {
|
|
logger.debug(
|
|
`Failure updating the ${is4k ? '4K' : 'non-4K'} ${
|
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
|
} [TMDB ID ${media.tmdbId}].`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private async seasonUpdater(
|
|
media: Media,
|
|
seasons: Map<number, boolean>,
|
|
is4k: boolean,
|
|
mediaServerType: MediaServerType
|
|
): Promise<void> {
|
|
const mediaRepository = getRepository(Media);
|
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
|
|
|
const seasonsPendingRemoval = new Map(
|
|
// Disabled linter as only the value is needed from the filter
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
[...seasons].filter(([_, exists]) => !exists)
|
|
);
|
|
const seasonKeys = [...seasonsPendingRemoval.keys()];
|
|
|
|
// let isSeasonRemoved = false;
|
|
|
|
try {
|
|
// Need to check and see if there are any related season
|
|
// requests. If they are, we will need to delete them.
|
|
const seasonRequests = await seasonRequestRepository
|
|
.createQueryBuilder('seasonRequest')
|
|
.leftJoinAndSelect('seasonRequest.request', 'request')
|
|
.leftJoinAndSelect('request.media', 'media')
|
|
.where('(media.id = :id)', { id: media.id })
|
|
.andWhere(
|
|
'(request.is4k = :is4k AND seasonRequest.seasonNumber IN (:...seasonNumbers))',
|
|
{
|
|
seasonNumbers: seasonKeys,
|
|
is4k: is4k,
|
|
}
|
|
)
|
|
.getMany();
|
|
|
|
for (const mediaSeason of media.seasons) {
|
|
if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) {
|
|
mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
|
}
|
|
}
|
|
|
|
if (media.status === MediaStatus.AVAILABLE) {
|
|
media.status = MediaStatus.PARTIALLY_AVAILABLE;
|
|
logger.info(
|
|
`Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
|
{ label: 'AvailabilitySync' }
|
|
);
|
|
}
|
|
|
|
if (media.status4k === MediaStatus.AVAILABLE) {
|
|
media.status4k = MediaStatus.PARTIALLY_AVAILABLE;
|
|
logger.info(
|
|
`Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`,
|
|
{ label: 'AvailabilitySync' }
|
|
);
|
|
}
|
|
|
|
await mediaRepository.save({ media, ...media });
|
|
|
|
if (seasonRequests.length > 0) {
|
|
await seasonRequestRepository.remove(seasonRequests);
|
|
}
|
|
|
|
logger.info(
|
|
`The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${
|
|
media.tmdbId
|
|
}] was not found in any ${
|
|
media.mediaType === 'tv' ? 'Sonarr' : 'Radarr'
|
|
} and ${
|
|
mediaServerType === MediaServerType.PLEX
|
|
? 'plex'
|
|
: mediaServerType === MediaServerType.JELLYFIN
|
|
? 'jellyfin'
|
|
: 'emby'
|
|
} instance. Status will be changed to unknown.`,
|
|
{ label: 'AvailabilitySync' }
|
|
);
|
|
} catch (ex) {
|
|
logger.debug(
|
|
`Failure updating the ${
|
|
is4k ? '4K' : 'non-4K'
|
|
} season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private async mediaExistsInRadarr(
|
|
media: Media,
|
|
is4k: boolean
|
|
): Promise<boolean> {
|
|
let existsInRadarr = false;
|
|
|
|
// Check for availability in all of the available radarr servers
|
|
// If any find the media, we will assume the media exists
|
|
for (const server of this.radarrServers) {
|
|
const radarrAPI = new RadarrAPI({
|
|
apiKey: server.apiKey,
|
|
url: RadarrAPI.buildUrl(server, '/api/v3'),
|
|
});
|
|
|
|
try {
|
|
let radarr: RadarrMovie | undefined;
|
|
|
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
|
radarr = await radarrAPI.getMovie({
|
|
id: media.externalServiceId,
|
|
});
|
|
}
|
|
|
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
|
radarr = await radarrAPI.getMovie({
|
|
id: media.externalServiceId4k,
|
|
});
|
|
}
|
|
|
|
if (radarr && radarr.hasFile) {
|
|
existsInRadarr = true;
|
|
}
|
|
} catch (ex) {
|
|
if (!ex.message.includes('404')) {
|
|
existsInRadarr = true;
|
|
logger.debug(
|
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
|
media.tmdbId
|
|
}] from Radarr.`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return existsInRadarr;
|
|
}
|
|
|
|
private async mediaExistsInSonarr(
|
|
media: Media,
|
|
is4k: boolean
|
|
): Promise<{ existsInSonarr: boolean; seasonsMap: Map<number, boolean> }> {
|
|
let existsInSonarr = false;
|
|
let preventSeasonSearch = false;
|
|
|
|
// Check for availability in all of the available sonarr servers
|
|
// If any find the media, we will assume the media exists
|
|
for (const server of this.sonarrServers) {
|
|
const sonarrAPI = new SonarrAPI({
|
|
apiKey: server.apiKey,
|
|
url: SonarrAPI.buildUrl(server, '/api/v3'),
|
|
});
|
|
|
|
try {
|
|
let sonarr: SonarrSeries | undefined;
|
|
|
|
if (!server.is4k && media.externalServiceId && !is4k) {
|
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId);
|
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] =
|
|
sonarr.seasons;
|
|
}
|
|
|
|
if (server.is4k && media.externalServiceId4k && is4k) {
|
|
sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k);
|
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] =
|
|
sonarr.seasons;
|
|
}
|
|
|
|
if (sonarr && sonarr.statistics.episodeFileCount > 0) {
|
|
existsInSonarr = true;
|
|
}
|
|
} catch (ex) {
|
|
if (!ex.message.includes('404')) {
|
|
existsInSonarr = true;
|
|
preventSeasonSearch = true;
|
|
logger.debug(
|
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
|
media.tmdbId
|
|
}] from Sonarr.`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Here we check each season for availability
|
|
// If the API returns an error other than a 404,
|
|
// we will have to prevent the season check from happening
|
|
const seasonsMap: Map<number, boolean> = new Map();
|
|
|
|
if (!preventSeasonSearch) {
|
|
const filteredSeasons = media.seasons.filter(
|
|
(season) =>
|
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
|
season[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PARTIALLY_AVAILABLE
|
|
);
|
|
|
|
for (const season of filteredSeasons) {
|
|
const seasonExists = await this.seasonExistsInSonarr(
|
|
media,
|
|
season,
|
|
is4k
|
|
);
|
|
|
|
if (seasonExists) {
|
|
seasonsMap.set(season.seasonNumber, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { existsInSonarr, seasonsMap };
|
|
}
|
|
|
|
private async seasonExistsInSonarr(
|
|
media: Media,
|
|
season: Season,
|
|
is4k: boolean
|
|
): Promise<boolean> {
|
|
let seasonExists = false;
|
|
|
|
// Check each sonarr instance to see if the media still exists
|
|
// If found, we will assume the media exists and prevent removal
|
|
// We can use the cache we built when we fetched the series with mediaExistsInSonarr
|
|
for (const server of this.sonarrServers) {
|
|
let sonarrSeasons: SonarrSeason[] | undefined;
|
|
|
|
if (media.externalServiceId && !is4k) {
|
|
sonarrSeasons =
|
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`];
|
|
}
|
|
|
|
if (media.externalServiceId4k && is4k) {
|
|
sonarrSeasons =
|
|
this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`];
|
|
}
|
|
|
|
const seasonIsAvailable = sonarrSeasons?.find(
|
|
({ seasonNumber, statistics }) =>
|
|
season.seasonNumber === seasonNumber &&
|
|
statistics?.episodeFileCount &&
|
|
statistics?.episodeFileCount > 0
|
|
);
|
|
|
|
if (seasonIsAvailable && sonarrSeasons) {
|
|
seasonExists = true;
|
|
}
|
|
}
|
|
|
|
return seasonExists;
|
|
}
|
|
|
|
// Plex
|
|
private async mediaExistsInPlex(
|
|
media: Media,
|
|
is4k: boolean
|
|
): Promise<{ existsInPlex: boolean; seasonsMap?: Map<number, boolean> }> {
|
|
const ratingKey = media.ratingKey;
|
|
const ratingKey4k = media.ratingKey4k;
|
|
let existsInPlex = false;
|
|
let preventSeasonSearch = false;
|
|
|
|
// Check each plex instance to see if the media still exists
|
|
// If found, we will assume the media exists and prevent removal
|
|
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
|
try {
|
|
let plexMedia: PlexMetadata | undefined;
|
|
|
|
if (ratingKey && !is4k) {
|
|
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
|
|
|
if (media.mediaType === 'tv') {
|
|
this.plexSeasonsCache[ratingKey] =
|
|
await this.plexClient?.getChildrenMetadata(ratingKey);
|
|
}
|
|
}
|
|
|
|
if (ratingKey4k && is4k) {
|
|
plexMedia = await this.plexClient?.getMetadata(ratingKey4k);
|
|
|
|
if (media.mediaType === 'tv') {
|
|
this.plexSeasonsCache[ratingKey4k] =
|
|
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
|
}
|
|
}
|
|
|
|
if (plexMedia) {
|
|
existsInPlex = true;
|
|
}
|
|
} catch (ex) {
|
|
if (!ex.message.includes('404')) {
|
|
existsInPlex = true;
|
|
preventSeasonSearch = true;
|
|
logger.debug(
|
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
|
} [TMDB ID ${media.tmdbId}] from Plex.`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Here we check each season in plex for availability
|
|
// If the API returns an error other than a 404,
|
|
// we will have to prevent the season check from happening
|
|
if (media.mediaType === 'tv') {
|
|
const seasonsMap: Map<number, boolean> = new Map();
|
|
|
|
if (!preventSeasonSearch) {
|
|
const filteredSeasons = media.seasons.filter(
|
|
(season) =>
|
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
|
season[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PARTIALLY_AVAILABLE
|
|
);
|
|
|
|
for (const season of filteredSeasons) {
|
|
const seasonExists = await this.seasonExistsInPlex(
|
|
media,
|
|
season,
|
|
is4k
|
|
);
|
|
|
|
if (seasonExists) {
|
|
seasonsMap.set(season.seasonNumber, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { existsInPlex, seasonsMap };
|
|
}
|
|
|
|
return { existsInPlex };
|
|
}
|
|
|
|
private async seasonExistsInPlex(
|
|
media: Media,
|
|
season: Season,
|
|
is4k: boolean
|
|
): Promise<boolean> {
|
|
const ratingKey = media.ratingKey;
|
|
const ratingKey4k = media.ratingKey4k;
|
|
let seasonExistsInPlex = false;
|
|
|
|
// Check each plex instance to see if the season exists
|
|
let plexSeasons: PlexMetadata[] | undefined;
|
|
|
|
if (ratingKey && !is4k) {
|
|
plexSeasons = this.plexSeasonsCache[ratingKey];
|
|
}
|
|
|
|
if (ratingKey4k && is4k) {
|
|
plexSeasons = this.plexSeasonsCache[ratingKey4k];
|
|
}
|
|
|
|
const seasonIsAvailable = plexSeasons?.find(
|
|
(plexSeason) => plexSeason.index === season.seasonNumber
|
|
);
|
|
|
|
if (seasonIsAvailable) {
|
|
seasonExistsInPlex = true;
|
|
}
|
|
|
|
return seasonExistsInPlex;
|
|
}
|
|
|
|
// Jellyfin
|
|
private async mediaExistsInJellyfin(
|
|
media: Media,
|
|
is4k: boolean
|
|
): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map<number, boolean> }> {
|
|
const ratingKey = media.jellyfinMediaId;
|
|
const ratingKey4k = media.jellyfinMediaId4k;
|
|
let existsInJellyfin = false;
|
|
let preventSeasonSearch = false;
|
|
|
|
// Check each jellyfin instance to see if the media still exists
|
|
// If found, we will assume the media exists and prevent removal
|
|
// We can use the cache we built when we fetched the series with mediaExistsInJellyfin
|
|
try {
|
|
let jellyfinMedia: JellyfinLibraryItem | undefined;
|
|
|
|
if (ratingKey && !is4k) {
|
|
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey);
|
|
|
|
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
|
|
this.jellyfinSeasonsCache[ratingKey] =
|
|
await this.jellyfinClient?.getSeasons(ratingKey);
|
|
}
|
|
}
|
|
|
|
if (ratingKey4k && is4k) {
|
|
jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k);
|
|
|
|
if (media.mediaType === 'tv' && jellyfinMedia !== undefined) {
|
|
this.jellyfinSeasonsCache[ratingKey4k] =
|
|
await this.jellyfinClient?.getSeasons(ratingKey4k);
|
|
}
|
|
}
|
|
|
|
if (jellyfinMedia) {
|
|
existsInJellyfin = true;
|
|
}
|
|
} catch (ex) {
|
|
if (!ex.message.includes('404' || '500')) {
|
|
existsInJellyfin = false;
|
|
preventSeasonSearch = true;
|
|
logger.debug(
|
|
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
|
media.mediaType === 'tv' ? 'show' : 'movie'
|
|
} [TMDB ID ${media.tmdbId}] from Jellyfin.`,
|
|
{
|
|
errorMessage: ex.message,
|
|
label: 'AvailabilitySync',
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// Here we check each season in jellyfin for availability
|
|
// If the API returns an error other than a 404,
|
|
// we will have to prevent the season check from happening
|
|
if (media.mediaType === 'tv') {
|
|
const seasonsMap: Map<number, boolean> = new Map();
|
|
|
|
if (!preventSeasonSearch) {
|
|
const filteredSeasons = media.seasons.filter(
|
|
(season) =>
|
|
season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
|
|
season[is4k ? 'status4k' : 'status'] ===
|
|
MediaStatus.PARTIALLY_AVAILABLE
|
|
);
|
|
|
|
for (const season of filteredSeasons) {
|
|
const seasonExists = await this.seasonExistsInJellyfin(
|
|
media,
|
|
season,
|
|
is4k
|
|
);
|
|
|
|
if (seasonExists) {
|
|
seasonsMap.set(season.seasonNumber, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { existsInJellyfin, seasonsMap };
|
|
}
|
|
|
|
return { existsInJellyfin };
|
|
}
|
|
|
|
private async seasonExistsInJellyfin(
|
|
media: Media,
|
|
season: Season,
|
|
is4k: boolean
|
|
): Promise<boolean> {
|
|
const ratingKey = media.jellyfinMediaId;
|
|
const ratingKey4k = media.jellyfinMediaId4k;
|
|
let seasonExistsInJellyfin = false;
|
|
|
|
// Check each jellyfin instance to see if the season exists
|
|
let jellyfinSeasons: JellyfinLibraryItem[] | undefined;
|
|
|
|
if (ratingKey && !is4k) {
|
|
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey];
|
|
}
|
|
|
|
if (ratingKey4k && is4k) {
|
|
jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k];
|
|
}
|
|
|
|
const seasonIsAvailable = jellyfinSeasons?.find(
|
|
(jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber
|
|
);
|
|
|
|
if (seasonIsAvailable) {
|
|
seasonExistsInJellyfin = true;
|
|
}
|
|
|
|
return seasonExistsInJellyfin;
|
|
}
|
|
}
|
|
|
|
const availabilitySync = new AvailabilitySync();
|
|
export default availabilitySync;
|