Files
seerr/server/lib/scanners/sonarr/index.ts

277 lines
8.7 KiB
TypeScript

import { getMetadataProvider } from '@server/api/metadata';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type {
TmdbKeyword,
TmdbTvDetails,
} from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;
servers: SonarrSettings[];
};
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus>
{
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
private scannedTvdbIds: Set<number> = new Set();
private scanned4kTvdbIds: Set<number> = new Set();
private didScanStandard = false;
private didScan4k = false;
constructor() {
super('Sonarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
this.scannedTvdbIds.clear();
this.scanned4kTvdbIds.clear();
this.didScanStandard = false;
this.didScan4k = false;
try {
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
const server4k = this.enable4kShow && server.is4k;
if (server4k) {
this.didScan4k = true;
} else {
this.didScanStandard = true;
}
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
// Only run cleanup if all servers of this profile type have sync enabled.
// If any server is skipped, we can't distinguish truly orphaned media from
// media that exists on an unscanned server (e.g. separate instances for
// anime, regional content, or different languages).
const allStandardScanned = this.servers
.filter((s) => !this.enable4kShow || !s.is4k)
.every((s) => s.syncEnabled);
const all4kScanned = this.servers
.filter((s) => this.enable4kShow && s.is4k)
.every((s) => s.syncEnabled);
if (!allStandardScanned) {
this.didScanStandard = false;
}
if (!all4kScanned) {
this.didScan4k = false;
}
await this.cleanupOrphanedShows();
this.log('Sonarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
const server4k = this.enable4kShow && this.currentServer.is4k;
if (server4k) {
this.scanned4kTvdbIds.add(sonarrSeries.tvdbId);
} else {
this.scannedTvdbIds.add(sonarrSeries.tvdbId);
}
try {
const mediaRepository = getRepository(Media);
const processableSeasons: ProcessableSeason[] = [];
let tvShow: TmdbTvDetails;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
} else {
tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId });
}
const tmdbId = tvShow.id;
const metadataProvider = tvShow.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
)
? await getMetadataProvider('anime')
: await getMetadataProvider('tv');
if (!(metadataProvider instanceof TheMovieDb)) {
tvShow = await metadataProvider.getTvShow({ tvId: tmdbId });
}
const settings = getSettings();
const filteredSeasons = tvShow.seasons
.filter(
(sn) => settings.main.enableSpecialEpisodes || sn.season_number !== 0
)
.map((season) => {
const sonarrSeason = sonarrSeries.seasons.find(
(s) => s.seasonNumber === season.season_number
);
if (!sonarrSeason) {
return {
seasonNumber: season.season_number,
episodeCount: season.episode_count,
monitored: false,
statistics: {
episodeFileCount: 0,
totalEpisodeCount: season.episode_count,
},
};
} else {
return sonarrSeason;
}
});
for (const season of filteredSeasons) {
const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0;
processableSeasons.push({
seasonNumber: season.seasonNumber,
episodes: !server4k ? totalAvailableEpisodes : 0,
episodes4k: server4k ? totalAvailableEpisodes : 0,
totalEpisodes: season.statistics?.totalEpisodeCount ?? 0,
processing: season.monitored && totalAvailableEpisodes === 0,
is4kOverride: server4k,
});
}
await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, {
serviceId: this.currentServer.id,
externalServiceId: sonarrSeries.id,
externalServiceSlug: sonarrSeries.titleSlug,
title: sonarrSeries.title,
is4k: server4k,
});
} catch (e) {
this.log('Failed to process Sonarr media', 'error', {
errorMessage: e.message,
title: sonarrSeries.title,
});
}
}
private async cleanupOrphanedShows(): Promise<void> {
const mediaRepository = getRepository(Media);
if (this.didScanStandard) {
const processingShows = await mediaRepository.find({
where: { mediaType: MediaType.TV, status: MediaStatus.PROCESSING },
relations: ['seasons'],
});
for (const media of processingShows) {
if (media.tvdbId && !this.scannedTvdbIds.has(media.tvdbId)) {
media.status = MediaStatus.UNKNOWN;
for (const season of media.seasons) {
if (season.status === MediaStatus.PROCESSING) {
season.status = MediaStatus.UNKNOWN;
}
}
await mediaRepository.save(media);
this.log(
`Show ${media.tmdbId} (tvdb: ${media.tvdbId}) not found in any Sonarr server. Status reset to UNKNOWN.`,
'info'
);
}
}
} else {
this.log(
'Skipping orphaned show cleanup: no standard Sonarr servers were scanned.',
'info'
);
}
if (this.didScan4k) {
const processing4kShows = await mediaRepository.find({
where: { mediaType: MediaType.TV, status4k: MediaStatus.PROCESSING },
relations: ['seasons'],
});
for (const media of processing4kShows) {
if (media.tvdbId && !this.scanned4kTvdbIds.has(media.tvdbId)) {
media.status4k = MediaStatus.UNKNOWN;
for (const season of media.seasons) {
if (season.status4k === MediaStatus.PROCESSING) {
season.status4k = MediaStatus.UNKNOWN;
}
}
await mediaRepository.save(media);
this.log(
`Show ${media.tmdbId} (tvdb: ${media.tvdbId}) not found in any 4K Sonarr server. 4K status reset to UNKNOWN.`,
'info'
);
}
}
} else if (this.enable4kShow) {
this.log(
'Skipping orphaned 4K show cleanup: no 4K Sonarr servers were scanned.',
'info'
);
}
}
}
export const sonarrScanner = new SonarrScanner();