feat(scan): add support for new plex tv agent (#1144)

This commit is contained in:
sct
2021-03-11 12:19:11 +09:00
committed by GitHub
parent fc73592b69
commit a51d2a24d5
9 changed files with 1327 additions and 1575 deletions

View File

@@ -0,0 +1,616 @@
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import TheMovieDb from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import logger from '../../logger';
import AsyncLock from '../../utils/asyncLock';
import { getSettings } from '../settings';
// Default scan rates (can be overidden)
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
export type StatusBase = {
running: boolean;
progress: number;
total: number;
};
export interface RunnableScanner<T> {
run: () => Promise<void>;
status: () => T & StatusBase;
}
export interface MediaIds {
tmdbId: number;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
}
interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
title?: string;
processing?: boolean;
}
export interface ProcessableSeason {
seasonNumber: number;
totalEpisodes: number;
episodes: number;
episodes4k: number;
is4kOverride?: boolean;
processing?: boolean;
}
class BaseScanner<T> {
private bundleSize;
private updateRate;
protected progress = 0;
protected items: T[] = [];
protected scannerName: string;
protected enable4kMovie = false;
protected enable4kShow = false;
protected sessionId: string;
protected running = false;
readonly asyncLock = new AsyncLock();
readonly tmdb = new TheMovieDb();
protected constructor(
scannerName: string,
{
updateRate,
bundleSize,
}: {
updateRate?: number;
bundleSize?: number;
} = {}
) {
this.scannerName = scannerName;
this.bundleSize = bundleSize ?? BUNDLE_SIZE;
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
return existing;
}
protected async processMovie(
tmdbId: number,
{
is4k = false,
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const existing = await this.getExisting(tmdbId, MediaType.MOVIE);
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
) {
existing[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
changedExisting = true;
}
if (
externalServiceSlug !== undefined &&
existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
externalServiceSlug
) {
existing[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changed were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.status =
!is4k && !processing
? MediaStatus.AVAILABLE
: !is4k && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.status4k =
is4k && this.enable4kMovie && !processing
? MediaStatus.AVAILABLE
: is4k && this.enable4kMovie && processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.serviceId = !is4k ? serviceId : undefined;
newMedia.serviceId4k = is4k ? serviceId : undefined;
newMedia.externalServiceId = !is4k ? externalServiceId : undefined;
newMedia.externalServiceId4k = is4k ? externalServiceId : undefined;
newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined;
newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = !is4k ? ratingKey : undefined;
newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* processShow takes a TMDb ID and an array of ProcessableSeasons, which
* should include the total episodes a sesaon has + the total available
* episodes that each season currently has. Unlike processMovie, this method
* does not take an `is4k` option. We handle both the 4k _and_ non 4k status
* in one method.
*
* Note: If 4k is not enable, ProcessableSeasons should combine their episode counts
* into the normal episodes properties and avoid using the 4k properties.
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
externalServiceSlug,
is4k = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbId, async () => {
const media = await this.getExisting(tmdbId, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonsAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
for (const season of seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
if (
media &&
season.episodes4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// Here we update seasons if they already exist.
// If the season is already marked as available, we
// force it to stay available (to avoid competing scanners)
existingSeason.status =
(season.totalEpisodes === season.episodes && season.episodes > 0) ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
existingSeason.status4k =
(this.enable4kShow &&
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
season.totalEpisodes === season.episodes && season.episodes > 0
? MediaStatus.AVAILABLE
: season.episodes > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow &&
season.totalEpisodes === season.episodes4k &&
season.episodes4k > 0
? MediaStatus.AVAILABLE
: this.enable4kShow && season.episodes4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.is4kOverride && season.processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
const isAllStandardSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes === season.totalEpisodes && season.episodes > 0
);
const isAll4kSeasons =
seasons.length &&
seasons.every(
(season) =>
season.episodes4k === season.totalEpisodes && season.episodes4k > 0
);
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonsAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonsAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) {
this.log(
`Detected ${
newStandardSeasonsAvailable - currentStandardSeasonsAvailable
} new standard season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
if (mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
}
if (new4kSeasonsAvailable > current4kSeasonsAvailable) {
this.log(
`Detected ${
new4kSeasonsAvailable - current4kSeasonsAvailable
} new 4K season(s) for ${title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt && mediaAddedAt) {
media.mediaAddedAt = mediaAddedAt;
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[
is4k ? 'externalServiceId4k' : 'externalServiceId'
] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
media[
is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'
] = externalServiceSlug;
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status !== MediaStatus.UNKNOWN)
.length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter((season) => season.status4k !== MediaStatus.UNKNOWN)
.length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: media.seasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId,
tvdbId,
mediaAddedAt,
serviceId: !is4k ? serviceId : undefined,
serviceId4k: is4k ? serviceId : undefined,
externalServiceId: !is4k ? externalServiceId : undefined,
externalServiceId4k: is4k ? externalServiceId : undefined,
externalServiceSlug: !is4k ? externalServiceSlug : undefined,
externalServiceSlug4k: is4k ? externalServiceSlug : undefined,
ratingKey: newSeasons.some(
(sn) =>
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
ratingKey4k:
this.enable4kShow &&
newSeasons.some(
(sn) =>
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
sn.status4k === MediaStatus.AVAILABLE
)
? ratingKey
: undefined,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) =>
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
season.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) =>
season.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
season.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: newSeasons.some(
(season) => season.status4k === MediaStatus.PROCESSING
)
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set
*
* Returns the session ID which is requried for the cleanup method
*/
protected startRun(): string {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Scan starting', 'info', { sessionId });
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
this.running = true;
return sessionId;
}
/**
* Call at end of run loop to perform cleanup
*/
protected endRun(sessionId: string): void {
if (this.sessionId === sessionId) {
this.running = false;
}
}
public cancel(): void {
this.running = false;
}
protected async loop(
processFn: (item: T) => Promise<void>,
{
start = 0,
end = this.bundleSize,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}
): Promise<void> {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(processFn, slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop(processFn, {
start: start + this.bundleSize,
end: end + this.bundleSize,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, this.updateRate)
);
}
}
private async processItems(
processFn: (items: T) => Promise<void>,
items: T[]
) {
await Promise.all(
items.map(async (item) => {
await processFn(item);
})
);
}
protected log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: this.scannerName, ...optional });
}
}
export default BaseScanner;

View File

@@ -0,0 +1,463 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import animeList from '../../../api/animelist';
import PlexAPI, { PlexLibraryItem, PlexMetadata } from '../../../api/plexapi';
import { TmdbTvDetails } from '../../../api/themoviedb/interfaces';
import { User } from '../../../entity/User';
import { getSettings, Library } from '../../settings';
import BaseScanner, {
MediaIds,
RunnableScanner,
StatusBase,
ProcessableSeason,
} from '../baseScanner';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
// https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id
const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/);
const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/);
const HAMA_AGENT = 'com.plexapp.agents.hama';
type SyncStatus = StatusBase & {
currentLibrary: Library;
libraries: Library[];
};
class PlexScanner
extends BaseScanner<PlexLibraryItem>
implements RunnableScanner<SyncStatus> {
private plexClient: PlexAPI;
private libraries: Library[];
private currentLibrary: Library;
private isRecentOnly = false;
public constructor(isRecentOnly = false) {
super('Plex Scan');
this.isRecentOnly = isRecentOnly;
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentLibrary: this.currentLibrary,
libraries: this.libraries,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
if (!admin) {
return this.log('No admin configured. Plex scan skipped.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();
}
if (this.isRecentOnly) {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
const libraryItems = await this.plexClient.getRecentlyAdded(
library.id
);
// Bundle items up by rating keys
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
return (
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
);
}
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
return mediaA.parentRatingKey === mediaB.parentRatingKey;
}
return mediaA.ratingKey === mediaB.ratingKey;
});
await this.loop(this.processItem.bind(this), { sessionId });
}
} else {
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.plexClient.getLibraryContents(library.id);
await this.loop(this.processItem.bind(this), { sessionId });
}
}
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processItem(plexitem: PlexLibraryItem) {
try {
if (plexitem.type === 'movie') {
await this.processPlexMovie(plexitem);
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
errorMessage: e.message,
title: plexitem.title,
});
}
}
private async processPlexMovie(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexMovieByTmdbId(
plexitem: PlexMetadata,
tmdbId: number
) {
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
}
private async processPlexShow(plexitem: PlexLibraryItem) {
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
const mediaIds = await this.getMediaIds(metadata);
// If the media is from HAMA, and doesn't have a TVDb ID, we will treat it
// as a special HAMA movie
if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) {
this.processHamaMovie(metadata, mediaIds.tmdbId);
return;
}
// If the media is from HAMA and we have a TVDb ID, we will attempt
// to process any specials that may exist
if (mediaIds.tvdbId && mediaIds.isHama) {
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
if (matchedPlexSeason) {
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
!this.enable4kShow
? true
: episode.Media.some((media) => media.videoResolution !== '4k')
).length;
// Total episodes that are in 4k
const total4k = this.enable4kShow
? episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length
: 0;
processableSeasons.push({
seasonNumber: season.season_number,
episodes: totalStandard,
episodes4k: total4k,
totalEpisodes: season.episode_count,
});
} else {
processableSeasons.push({
seasonNumber: season.season_number,
episodes: 0,
episodes4k: 0,
totalEpisodes: season.episode_count,
});
}
}
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
mediaAddedAt: new Date(metadata.addedAt * 1000),
ratingKey: ratingKey,
title: metadata.title,
}
);
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
const mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
// If there is no Guid field at all, then we bail
if (!metadata.Guid) {
throw new Error(
'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)'
);
}
// Map all IDs to MediaId object
metadata.Guid.forEach((ref) => {
if (ref.id.match(imdbRegex)) {
mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined;
} else if (ref.id.match(tmdbRegex)) {
const tmdbMatch = ref.id.match(tmdbRegex)?.[1];
mediaIds.tmdbId = Number(tmdbMatch);
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
}
});
// If we got an IMDb ID, but no TMDb ID, lookup the TMDb ID with the IMDb ID
if (mediaIds.imdbId && !mediaIds.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is IMDb
} else if (plexitem.guid.match(imdbRegex)) {
const imdbMatch = plexitem.guid.match(imdbRegex);
if (imdbMatch) {
mediaIds.imdbId = imdbMatch[1];
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: mediaIds.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
}
// Check if the agent is TMDb
} else if (plexitem.guid.match(tmdbRegex)) {
const tmdbMatch = plexitem.guid.match(tmdbRegex);
if (tmdbMatch) {
mediaIds.tmdbId = Number(tmdbMatch[1]);
}
// Check if the agent is TVDb
} else if (plexitem.guid.match(tvdbRegex)) {
const matchedtvdb = plexitem.guid.match(tvdbRegex);
// If we can find a tvdb Id, use it to get the full tmdb show details
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
}
// Check if the agent (for shows) is TMDb
} else if (plexitem.guid.match(tmdbShowRegex)) {
const matchedtmdb = plexitem.guid.match(tmdbShowRegex);
if (matchedtmdb) {
mediaIds.tmdbId = Number(matchedtmdb[1]);
}
// Check for HAMA (with TVDb guid)
} else if (plexitem.guid.match(hamaTvdbRegex)) {
const matchedtvdb = plexitem.guid.match(hamaTvdbRegex);
if (matchedtvdb) {
const show = await this.tmdb.getShowByTvdbId({
tvdbId: Number(matchedtvdb[1]),
});
mediaIds.tvdbId = Number(matchedtvdb[1]);
mediaIds.tmdbId = show.id;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
}
// Check for HAMA (with anidb guid)
} else if (plexitem.guid.match(hamaAnidbRegex)) {
const matchedhama = plexitem.guid.match(hamaAnidbRegex);
if (!animeList.isLoaded()) {
this.log(
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn',
{ title: plexitem.title }
);
} else if (matchedhama) {
const anidbId = Number(matchedhama[1]);
const result = animeList.getFromAnidbId(anidbId);
let tvShow: TmdbTvDetails | null = null;
// Set isHama to true, so we can know to add special processing to this item
mediaIds.isHama = true;
// First try to lookup the show by TVDb ID
if (result?.tvdbId) {
const extResponse = await this.tmdb.getByExternalId({
externalId: result.tvdbId,
type: 'tvdb',
});
if (extResponse.tv_results[0]) {
tvShow = await this.tmdb.getTvShow({
tvId: extResponse.tv_results[0].id,
});
mediaIds.tvdbId = result.tvdbId;
mediaIds.tmdbId = tvShow.id;
} else {
this.log(
`Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}`
);
}
}
if (!tvShow) {
// if lookup of tvshow above failed, then try movie with tmdbid/imdbid
// note - some tv shows have imdbid set too, that's why this need to go second
if (result?.tmdbId) {
mediaIds.tmdbId = result.tmdbId;
mediaIds.imdbId = result?.imdbId;
} else if (result?.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: result.imdbId,
});
mediaIds.tmdbId = tmdbMovie.id;
mediaIds.imdbId = result.imdbId;
}
}
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
// We check above if we have the TMDb ID, so we can safely assert the type below
return mediaIds as MediaIds;
}
// movies with hama agent actually are tv shows with at least one episode in it
// try to get first episode of any season - cannot hardcode season or episode number
// because sometimes user can have it in other season/ep than s01e01
private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) {
const season = metadata.Children?.Metadata[0];
if (season) {
const episodes = await this.plexClient.getChildrenMetadata(
season.ratingKey
);
if (episodes) {
await this.processPlexMovieByTmdbId(episodes[0], tmdbId);
}
}
}
// this adds all movie episodes from specials season for Hama agent
private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) {
const specials = metadata.Children?.Metadata.find(
(md) => Number(md.index) === 0
);
if (specials) {
const episodes = await this.plexClient.getChildrenMetadata(
specials.ratingKey
);
if (episodes) {
for (const episode of episodes) {
const special = animeList.getSpecialEpisode(tvdbId, episode.index);
if (special) {
if (special.tmdbId) {
await this.processPlexMovieByTmdbId(episode, special.tmdbId);
} else if (special.imdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: special.imdbId,
});
await this.processPlexMovieByTmdbId(episode, tmdbMovie.id);
}
}
}
}
}
}
// checks if any of this.libraries has Hama agent set in Plex
private async hasHamaAgent() {
const plexLibraries = await this.plexClient.getLibraries();
return this.libraries.some((library) =>
plexLibraries.some(
(plexLibrary) =>
plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key
)
);
}
}
export const plexFullScanner = new PlexScanner();
export const plexRecentScanner = new PlexScanner(true);

View File

@@ -0,0 +1,94 @@
import { uniqWith } from 'lodash';
import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: RadarrSettings;
servers: RadarrSettings[];
};
class RadarrScanner
extends BaseScanner<RadarrMovie>
implements RunnableScanner<SyncStatus> {
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
constructor() {
super('Radarr 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();
try {
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop(this.processRadarrMovie.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processRadarrMovie(radarrMovie: RadarrMovie): Promise<void> {
try {
const server4k = this.enable4kMovie && this.currentServer.is4k;
await this.processMovie(radarrMovie.tmdbId, {
is4k: server4k,
serviceId: this.currentServer.id,
externalServiceId: radarrMovie.id,
externalServiceSlug: radarrMovie.titleSlug,
title: radarrMovie.title,
processing: !radarrMovie.downloaded,
});
} catch (e) {
this.log('Failed to process Radarr media', 'error', {
errorMessage: e.message,
title: radarrMovie.title,
});
}
}
}
export const radarrScanner = new RadarrScanner();

View File

@@ -0,0 +1,134 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '../baseScanner';
type SyncStatus = StatusBase & {
currentServer: SonarrSettings;
servers: SonarrSettings[];
};
class SonarrScanner
extends BaseScanner<SonarrSeries>
implements RunnableScanner<SyncStatus> {
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
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();
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.buildSonarrUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop(this.processSonarrSeries.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
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) {
try {
const mediaRepository = getRepository(Media);
const server4k = this.enable4kShow && this.currentServer.is4k;
const processableSeasons: ProcessableSeason[] = [];
let tmdbId: number;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
if (!media || !media.tmdbId) {
const tvShow = await this.tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
tmdbId = tvShow.id;
} else {
tmdbId = media.tmdbId;
}
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) => sn.seasonNumber !== 0
);
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,
});
}
}
}
export const sonarrScanner = new SonarrScanner();