diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2037bb..6a34298a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [1.7.1](https://github.com/jorenn92/Maintainerr/compare/v1.7.0...v1.7.1) (2024-01-06) + + +### Bug Fixes + +* **maintenance:** Extended the maintenance task with an action to remove orphaned collection objects ([f5826cc](https://github.com/jorenn92/Maintainerr/commit/f5826cc1f4e2997586ec1fa2cc704d7a85d01e8e)) +* **plex:** Fixed an issue where fetching Plex users would fail if connection to plex.tv failed ([2458a8f](https://github.com/jorenn92/Maintainerr/commit/2458a8f62797d3122e2577493f73948c85ab4c9b)) +* **rules:** Extended the Plex - rating rule ([ef95481](https://github.com/jorenn92/Maintainerr/commit/ef95481d8653d0d84bf3c00a92bf046b8abc50e6)) +* **rules:** Fixed an issue where 'Plex - Present in amount of other collections' wouldn't work as expected ([1c4accd](https://github.com/jorenn92/Maintainerr/commit/1c4accdacf17738878cb60bde60bd176b3dc6426)) +* **rules:** Fixed an issue where an item would be stuck inside the internal collection when it was removed manually ([1eae15f](https://github.com/jorenn92/Maintainerr/commit/1eae15f094ad081d20829db638f3cb44789f2137)) +* **rules:** Fixed an issue where the "Plex - Last episode added at" rule order was affected by the library's Plex Episode Sorting setting ([67299c4](https://github.com/jorenn92/Maintainerr/commit/67299c4d6f94aa2f104694e4fab265fe4767af70)) +* **rules:** Resolved an issue where a nullpointer could occur when fetching playlists. ([a0400b8](https://github.com/jorenn92/Maintainerr/commit/a0400b865999a986cfdcef6bb8603f8f0483e62b)) + # [1.7.0](https://github.com/jorenn92/Maintainerr/compare/v1.6.10...v1.7.0) (2023-12-21) diff --git a/Dockerfile b/Dockerfile index dfd53d2a..13edf57f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,10 +34,10 @@ RUN yarn run docs-generate && \ rm -rf ./docs RUN \ -case "${TARGETPLATFORM}" in ('linux/arm64' | 'linux/amd64') \ + case "${TARGETPLATFORM}" in ('linux/arm64' | 'linux/amd64') \ yarn add --save --network-timeout 99999999 sharp \ -;; \ -esac + ;; \ + esac RUN yarn --production --non-interactive --ignore-scripts --prefer-offline --frozen-lockfile --network-timeout 99999999 @@ -49,6 +49,9 @@ ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} +ARG DEBUG=false +ENV DEBUG=${DEBUG} + EXPOSE 80 WORKDIR /opt @@ -57,7 +60,7 @@ COPY --from=BUILDER /opt ./ COPY supervisord.conf /etc/supervisord.conf RUN apk add supervisor && \ - rm -rf /tmp/* && \ + rm -rf /tmp/* && \ mkdir /opt/data VOLUME [ "/opt/data" ] diff --git a/README.md b/README.md index 21f80f1f..05c7b5dc 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,25 @@ Docker pulls

-Maintainerr makes managing your media easy. - - Do you hate being the janitor of your server? - - Do you have a lot of media that never gets watched? - - Do your users constantly request media, and let it sit there afterward never to be touched again? - +Maintainerr makes managing your media easy. + +- Do you hate being the janitor of your server? +- Do you have a lot of media that never gets watched? +- Do your users constantly request media, and let it sit there afterward never to be touched again? + If you answered yes to any of those questions.. You NEED Maintainerr. It's a one-stop-shop for handling those outlying shows and movies that take up precious space on your server. # Features + - Configure rules specific to your needs, based off of several available options from Plex, Overseerr, Radarr, and Sonarr. - Manually add media to a collection, in case it's not included after rule execution. (one-off items that don't match a rule set) - Selectively exclude media from being added to a collection, even if it matches a rule. -- Show a collection, containing rule matched media, on the Plex home screen for a specific duration before deletion. Think "Leaving soon". +- Show a collection, containing rule matched media, on the Plex home screen for a specific duration before deletion. Think "Leaving soon". - Optionally, use a manual Plex collection, in case you don't want Maintainerr to add & remove Plex collections at will. - Manage media straight from the collection within Plex. Maintainerr will sync and add or exclude media to/from the internal collection. -- Remove or unmonitor media from *arr +- Remove or unmonitor media from \*arr - Clear requests from Overseerr - Delete files from disk @@ -39,14 +41,13 @@ Currently, Maintainerr supports rule parameters from these apps : - Overseerr - Radarr - Sonarr - -# Preview + +# Preview + ![image](https://github.com/ydkmlt84/Maintainerr/assets/2887742/8edabd29-ed98-4a9f-b41f-251b2e7d309c) ![image](https://github.com/ydkmlt84/Maintainerr/assets/2887742/c9916c90-4c67-4341-a0c1-32613518aa20) ![image](https://github.com/ydkmlt84/Maintainerr/assets/2887742/00740a16-e4fe-4429-a769-64ffcd568cba) - - # Installation Docker images for amd64, arm64 & armv7 are available under jorenn92/maintainerr.
@@ -55,6 +56,7 @@ Data is saved within the container under /opt/data, it is recommended to tie a p For more information, visit the [installation guide](docs/2-getting-started/1-installation/Installation.md) or navigate to \:\/docs after starting your Maintainerr container. Docker run: + ```Yaml docker run -d \ --name maintainerr \ @@ -64,7 +66,9 @@ docker run -d \ --restart unless-stopped \ jorenn92/maintainerr ``` -Docker-compose: + +Docker-compose: + ```Yaml version: '3' @@ -76,12 +80,14 @@ services: - :/opt/data environment: - TZ=Europe/Brussels +# - DEBUG=true # uncomment to enable verbose logs ports: - 8154:80 restart: unless-stopped ``` # Credits + Maintainerr is heavily inspired by Overseerr. Some parts of Maintainerr's code are plain copies. Big thanks to the Overseerr team for creating and maintaining such an amazing app! Please support them at https://github.com/sct/overseerr diff --git a/docs/2-getting-started/1-installation/Installation.md b/docs/2-getting-started/1-installation/Installation.md index b198c2dc..3ab0f585 100644 --- a/docs/2-getting-started/1-installation/Installation.md +++ b/docs/2-getting-started/1-installation/Installation.md @@ -48,6 +48,7 @@ services: - ./data:/opt/data environment: - TZ=Europe/Brussels +# - DEBUG=true # uncomment to enable verbose logs ports: - 8154:80 restart: unless-stopped diff --git a/package.json b/package.json index c9ada2e6..1cc0752d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maintainerr", - "version": "1.7.0", + "version": "1.7.1", "private": true, "repository": { "type": "git", diff --git a/server/src/main.ts b/server/src/main.ts index b45e24b5..25749a96 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -2,7 +2,12 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app/app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV !== 'production' || process.env.DEBUG == 'true' + ? ['log', 'debug', 'error', 'verbose', 'warn'] + : ['error', 'warn', 'log'], + }); app.enableCors(); await app.listen(3001); } diff --git a/server/src/modules/api/external-api/external-api.service.ts b/server/src/modules/api/external-api/external-api.service.ts index d6c622d1..8853e353 100644 --- a/server/src/modules/api/external-api/external-api.service.ts +++ b/server/src/modules/api/external-api/external-api.service.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import axios, { AxiosInstance, RawAxiosRequestConfig } from 'axios'; import NodeCache from 'node-cache'; @@ -53,6 +54,7 @@ export class ExternalApiService { return response.data; } catch (err) { + Logger.debug(`GET request failed: ${err}`); return undefined; } } @@ -64,6 +66,7 @@ export class ExternalApiService { try { return (await this.axios.get(endpoint, config)).data; } catch (err) { + Logger.debug(`GET request failed: ${err}`); return undefined; } } @@ -76,6 +79,7 @@ export class ExternalApiService { const response = await this.axios.delete(endpoint, config); return response.data; } catch (err) { + Logger.debug(`DELETE request failed: ${err}`); return undefined; } } @@ -89,6 +93,7 @@ export class ExternalApiService { const response = await this.axios.put(endpoint, data, config); return response.data; } catch (err) { + Logger.debug(`PUT request failed: ${err}`); return undefined; } } @@ -102,6 +107,7 @@ export class ExternalApiService { const response = await this.axios.post(endpoint, data, config); return response.data; } catch (err) { + Logger.debug(`POST request failed: ${err}`); return undefined; } } @@ -138,6 +144,7 @@ export class ExternalApiService { return response.data; } catch (err) { + Logger.debug(`GET request failed: ${err}`); return undefined; } } @@ -153,6 +160,7 @@ export class ExternalApiService { return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; } catch (err) { + Logger.debug(`Failed serializing cache key: ${err}`); return undefined; } } diff --git a/server/src/modules/api/lib/plexApi.ts b/server/src/modules/api/lib/plexApi.ts index 9f32c167..a3f2467a 100644 --- a/server/src/modules/api/lib/plexApi.ts +++ b/server/src/modules/api/lib/plexApi.ts @@ -1,6 +1,7 @@ import NodePlexAPI from 'plex-api'; import cacheManager, { Cache } from './cache'; import { AxiosHeaderValue } from 'axios'; +import { PlexLibraryResponse } from '../plex-api/interfaces/library.interfaces'; // NodePlexApi wrapped with a cache class PlexApi extends NodePlexAPI { @@ -16,6 +17,57 @@ class PlexApi extends NodePlexAPI { return this.queryWithCache(options, true); } + /** + * Queries all items with the given options, will fetch all pages. + * + * @param {any} options - The options for the query. + * @return {Promise} - A promise that resolves to an array of T. + */ + async queryAll(options): Promise { + // vars + let result = undefined; + let next = true; + let page = 0; + const size = 120; + options = { + ...options, + extraHeaders: { + ...options?.extraHeaders, + 'X-Plex-Container-Start': `${page}`, + 'X-Plex-Container-Size': `${size}`, + }, + }; + + // loop responses + while (next) { + const query: PlexLibraryResponse = await this.queryWithCache( + options, + true, + ); + if (result === undefined) { + // if first response, replace result + result = query; + } else { + // if next response, add to previous result + const items = this.getDataValue(query.MediaContainer); + + // if response is an array + if (items) { + this.appendToData(result.MediaContainer, items as any[]); + } + } + + // fetch all if more than 120 + if (query?.MediaContainer?.totalSize > size * (page + 1)) { + options.extraHeaders['X-Plex-Container-Start'] = `${size * (page + 1)}`; + page++; + } else { + next = false; + } + } + return result as unknown as T; + } + async queryWithCache(options, doCache: boolean): Promise { if (typeof options === 'string') { options = { @@ -55,6 +107,54 @@ class PlexApi extends NodePlexAPI { return undefined; } } + + /** + * Retrieves the first array value from an object. + * + * @param {Record} obj - The object to retrieve the value from. + * @returns {T | undefined} - The first array value found in the object, or undefined if no array value is found. + */ + private getDataValue(obj: Record): T | undefined { + const keys = Object.keys(obj); + + // Find the first key that has an array value + const arrayKey = keys.find((key) => Array.isArray(obj[key])); + + // If a key with an array value is found, return the corresponding value + if (arrayKey !== undefined) { + return obj[arrayKey]; + } else { + return undefined; // No key with an array value found + } + } + + /** + * Appends an array of items to the first array property in a given object. + * + * @param {Record} obj - The object to append the items to. + * @param {T[]} newItem - The items to append to the object. + * @returns {Record} - The object with the items appended to the specified property. + */ + private appendToData( + obj: Record, + newItem: T[], + ): Record { + const keys = Object.keys(obj); + + // Find the first key that has an array value + const arrayKey = keys.find((key) => Array.isArray(obj[key])); + + if (arrayKey !== undefined) { + const arrayValue = obj[arrayKey]; + + // Ensure that the value is an array + if (Array.isArray(arrayValue)) { + // If it's an array, append the new item + obj[arrayKey] = [...arrayValue, ...newItem] as T; + } + } + return obj; + } } export default PlexApi; diff --git a/server/src/modules/api/lib/plextvApi.ts b/server/src/modules/api/lib/plextvApi.ts index c4178c7f..d61b49ef 100644 --- a/server/src/modules/api/lib/plextvApi.ts +++ b/server/src/modules/api/lib/plextvApi.ts @@ -1,258 +1,252 @@ -import { Logger } from "@nestjs/common"; -import { ExternalApiService } from "../external-api/external-api.service"; -import cacheManager from "./cache"; +import { Logger } from '@nestjs/common'; +import { ExternalApiService } from '../external-api/external-api.service'; +import cacheManager from './cache'; import xml2js from 'xml2js'; - interface PlexAccountResponse { - user: PlexUser; + user: PlexUser; } interface PlexUser { - id: number; - uuid: string; - email: string; - joined_at: string; - username: string; - title: string; - thumb: string; - hasPassword: boolean; - authToken: string; - subscription: { - active: boolean; - status: string; - plan: string; - features: string[]; - }; - roles: { - roles: string[]; - }; - entitlements: string[]; + id: number; + uuid: string; + email: string; + joined_at: string; + username: string; + title: string; + thumb: string; + hasPassword: boolean; + authToken: string; + subscription: { + active: boolean; + status: string; + plan: string; + features: string[]; + }; + roles: { + roles: string[]; + }; + entitlements: string[]; } interface ConnectionResponse { - $: { - protocol: string; - address: string; - port: string; - uri: string; - local: string; - }; + $: { + protocol: string; + address: string; + port: string; + uri: string; + local: string; + }; } interface DeviceResponse { - $: { - name: string; - product: string; - productVersion: string; - platform: string; - platformVersion: string; - device: string; - clientIdentifier: string; - createdAt: string; - lastSeenAt: string; - provides: string; - owned: string; - accessToken?: string; - publicAddress?: string; - httpsRequired?: string; - synced?: string; - relay?: string; - dnsRebindingProtection?: string; - natLoopbackSupported?: string; - publicAddressMatches?: string; - presence?: string; - ownerID?: string; - home?: string; - sourceTitle?: string; - }; - Connection: ConnectionResponse[]; + $: { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: string; + lastSeenAt: string; + provides: string; + owned: string; + accessToken?: string; + publicAddress?: string; + httpsRequired?: string; + synced?: string; + relay?: string; + dnsRebindingProtection?: string; + natLoopbackSupported?: string; + publicAddressMatches?: string; + presence?: string; + ownerID?: string; + home?: string; + sourceTitle?: string; + }; + Connection: ConnectionResponse[]; } interface ServerResponse { - $: { - id: string; - serverId: string; - machineIdentifier: string; - name: string; - lastSeenAt: string; - numLibraries: string; - owned: string; - }; + $: { + id: string; + serverId: string; + machineIdentifier: string; + name: string; + lastSeenAt: string; + numLibraries: string; + owned: string; + }; } interface UsersResponse { - MediaContainer: { - User: { - $: { - id: string; - title: string; - username: string; - email: string; - thumb: string; - }; - Server: ServerResponse[]; - }[]; - }; + MediaContainer: { + User: { + $: { + id: string; + title: string; + username: string; + email: string; + thumb: string; + }; + Server: ServerResponse[]; + }[]; + }; } interface WatchlistResponse { - MediaContainer: { - totalSize: number; - Metadata?: { - ratingKey: string; - }[]; - }; + MediaContainer: { + totalSize: number; + Metadata?: { + ratingKey: string; + }[]; + }; } interface MetadataResponse { - MediaContainer: { - Metadata: { - ratingKey: string; - type: 'movie' | 'show'; - title: string; - Guid: { - id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; - }[]; - }[]; - }; + MediaContainer: { + Metadata: { + ratingKey: string; + type: 'movie' | 'show'; + title: string; + Guid: { + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + }[]; + }[]; + }; } export interface PlexWatchlistItem { - ratingKey: string; - tmdbId: number; - tvdbId?: number; - type: 'movie' | 'show'; - title: string; + ratingKey: string; + tmdbId: number; + tvdbId?: number; + type: 'movie' | 'show'; + title: string; } export class PlexTvApi extends ExternalApiService { - private authToken: string; - private readonly logger = new Logger(PlexTvApi.name); - - constructor( - authToken: string - ) { - super( - 'https://plex.tv', - {}, + private authToken: string; + private readonly logger = new Logger(PlexTvApi.name); + + constructor(authToken: string) { + super( + 'https://plex.tv', + {}, + { + headers: { + 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('plextv').data, + }, + ); + this.authToken = authToken; + } + + public async getUser(): Promise { + try { + const account = await this.get( + '/users/account.json', + ); + + return account.user; + } catch (e) { + this.logger.error( + `Something went wrong while getting the account from plex.tv: ${e.message}`, + { label: 'Plex.tv API' }, + ); + throw new Error('Invalid auth token'); + } + } + + public async getUsers(): Promise { + const response = await this.get('/api/users', { + transformResponse: [], + responseType: 'text', + }); + + const parsedXml = (await xml2js.parseStringPromise( + response, + )) as UsersResponse; + return parsedXml; + } + + public async getWatchlist({ + offset = 0, + size = 20, + }: { offset?: number; size?: number } = {}): Promise<{ + offset: number; + size: number; + totalSize: number; + items: PlexWatchlistItem[]; + }> { + try { + const response = await this.get( + '/library/sections/watchlist/all', + { + params: { + 'X-Plex-Container-Start': offset, + 'X-Plex-Container-Size': size, + }, + baseURL: 'https://metadata.provider.plex.tv', + }, + ); + + const watchlistDetails = await Promise.all( + (response.MediaContainer.Metadata ?? []).map(async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, { - headers: { - 'X-Plex-Token': authToken, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - nodeCache: cacheManager.getCache('plextv').data, + baseURL: 'https://metadata.provider.plex.tv', }, - ); - this.authToken = authToken; - } - - public async getUser(): Promise { - try { - const account = await this.get( - '/users/account.json' - ); - - return account.user; - } catch (e) { - this.logger.error( - `Something went wrong while getting the account from plex.tv: ${e.message}`, - { label: 'Plex.tv API' } - ); - throw new Error('Invalid auth token'); - } - } - - - public async getUsers(): Promise { - const response = await this.get('/api/users', { - transformResponse: [], - responseType: 'text', - }); - - const parsedXml = (await xml2js.parseStringPromise( - response - )) as UsersResponse; - return parsedXml; - } - - public async getWatchlist({ - offset = 0, - size = 20, - }: { offset?: number; size?: number } = {}): Promise<{ - offset: number; - size: number; - totalSize: number; - items: PlexWatchlistItem[]; - }> { - try { - const response = await this.get( - '/library/sections/watchlist/all', - { - params: { - 'X-Plex-Container-Start': offset, - 'X-Plex-Container-Size': size, - }, - baseURL: 'https://metadata.provider.plex.tv', - } - ); - - const watchlistDetails = await Promise.all( - (response.MediaContainer.Metadata ?? []).map( - async (watchlistItem) => { - const detailedResponse = await this.getRolling( - `/library/metadata/${watchlistItem.ratingKey}`, - { - baseURL: 'https://metadata.provider.plex.tv', - } - ); - - const metadata = detailedResponse.MediaContainer.Metadata[0]; - - const tmdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tmdb') - ); - const tvdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tvdb') - ); - - return { - ratingKey: metadata.ratingKey, - // This should always be set? But I guess it also cannot be? - // We will filter out the 0's afterwards - tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, - tvdbId: tvdbString - ? Number(tvdbString.id.split('//')[1]) - : undefined, - title: metadata.title, - type: metadata.type, - }; - } - ) - ); - - const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); - - return { - offset, - size, - totalSize: response.MediaContainer.totalSize, - items: filteredList, - }; - } catch (e) { - this.logger.error('Failed to retrieve watchlist items', { - label: 'Plex.TV Metadata API', - errorMessage: e.message, - }); - return { - offset, - size, - totalSize: 0, - items: [], - }; - } + ); + + const metadata = detailedResponse.MediaContainer.Metadata[0]; + + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb'), + ); + const tvdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tvdb'), + ); + + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString + ? Number(tvdbString.id.split('//')[1]) + : undefined, + title: metadata.title, + type: metadata.type, + }; + }), + ); + + const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + + return { + offset, + size, + totalSize: response.MediaContainer.totalSize, + items: filteredList, + }; + } catch (e) { + this.logger.error('Failed to retrieve watchlist items', { + label: 'Plex.TV Metadata API', + errorMessage: e.message, + }); + return { + offset, + size, + totalSize: 0, + items: [], + }; } + } } export default PlexTvApi; diff --git a/server/src/modules/api/overseerr-api/overseerr-api.service.ts b/server/src/modules/api/overseerr-api/overseerr-api.service.ts index b1026271..b1f078a3 100644 --- a/server/src/modules/api/overseerr-api/overseerr-api.service.ts +++ b/server/src/modules/api/overseerr-api/overseerr-api.service.ts @@ -124,7 +124,7 @@ export class OverseerrApiService { constructor( @Inject(forwardRef(() => SettingsService)) private readonly settings: SettingsService, - ) { } + ) {} public async init() { this.api = new OverseerrApi({ @@ -144,6 +144,7 @@ export class OverseerrApiService { this.logger.warn( 'Overseerr communication failed. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -164,6 +165,7 @@ export class OverseerrApiService { this.logger.warn( 'Overseerr communication failed. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -179,6 +181,7 @@ export class OverseerrApiService { 'Overseerr communication failed. Is the application running?', err, ); + this.logger.debug(err); return undefined; } } @@ -218,6 +221,7 @@ export class OverseerrApiService { 'Overseerr communication failed. Is the application running?', err, ); + this.logger.debug(err); return undefined; } } @@ -234,6 +238,7 @@ export class OverseerrApiService { errorMessage: e.message, mediaId, }); + this.logger.debug(e); return null; } } @@ -266,22 +271,26 @@ export class OverseerrApiService { this.logger.warn( 'Overseerr communication failed. Is the application running?', ); + this.logger.debug(err); return undefined; } } public async status(): Promise { try { - const response: OverseerrStatus = - await this.api.getWithoutCache(`/status`, { + const response: OverseerrStatus = await this.api.getWithoutCache( + `/status`, + { signal: AbortSignal.timeout(10000), // aborts request after 10 seconds - }); + }, + ); return response; } catch (e) { this.logger.log("Couldn't fetch Overseerr status!", { label: 'Overseerr API', errorMessage: e.message, }); + this.logger.debug(e); return null; } } diff --git a/server/src/modules/api/plex-api/interfaces/library.interfaces.ts b/server/src/modules/api/plex-api/interfaces/library.interfaces.ts index 11c72688..536fbec0 100644 --- a/server/src/modules/api/plex-api/interfaces/library.interfaces.ts +++ b/server/src/modules/api/plex-api/interfaces/library.interfaces.ts @@ -29,6 +29,7 @@ export interface PlexLibraryItem { originallyAvailableAt: string; rating?: number; audienceRating?: number; + userRating?: number; Genre?: PlexGenre[]; Role?: PlexActor[]; leafCount?: number; diff --git a/server/src/modules/api/plex-api/plex-api.service.ts b/server/src/modules/api/plex-api/plex-api.service.ts index 9f703a4c..47bf0b7f 100644 --- a/server/src/modules/api/plex-api/plex-api.service.ts +++ b/server/src/modules/api/plex-api/plex-api.service.ts @@ -104,10 +104,11 @@ export class PlexApiService { "Plex API isn't fully initialized, required settings aren't set", ); } - } catch (_err) { + } catch (err) { this.logger.error( `Couldn't connect to Plex.. Please check your settings`, ); + this.logger.debug(err); } } @@ -122,6 +123,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -133,12 +135,12 @@ export class PlexApiService { ); const results = response.MediaContainer.Metadata ? Promise.all( - response.MediaContainer.Metadata.map(async (el: PlexMetadata) => { - return el.grandparentRatingKey - ? await this.getMetadata(el.grandparentRatingKey.toString()) - : el; - }), - ) + response.MediaContainer.Metadata.map(async (el: PlexMetadata) => { + return el.grandparentRatingKey + ? await this.getMetadata(el.grandparentRatingKey.toString()) + : el; + }), + ) : []; const fileteredResults: PlexMetadata[] = []; (await results).forEach((el: PlexMetadata) => { @@ -153,64 +155,53 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } public async getUsers(): Promise { try { - const response: PlexAccountsResponse = - await this.plexClient.query({ - uri: '/accounts', - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, - }); + const response: PlexAccountsResponse = await this.plexClient.queryAll({ + uri: '/accounts', + }); return response.MediaContainer.Account; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } public async getUser(id: number): Promise { try { - const response: PlexAccountsResponse = - await this.plexClient.query({ - uri: `/accounts/${id}`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, - }); + const response: PlexAccountsResponse = await this.plexClient.queryAll({ + uri: `/accounts/${id}`, + }); return response.MediaContainer.Account; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } public async getLibraries(): Promise { try { - const response = - await this.plexClient.query({ - uri: '/library/sections', - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, - }); + const response = await this.plexClient.queryAll({ + uri: '/library/sections', + }); return response.MediaContainer.Directory; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -238,6 +229,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -248,9 +240,10 @@ export class PlexApiService { ): Promise { try { const response = await this.plexClient.query( - `/library/metadata/${key}${options.includeChildren - ? '?includeChildren=1&includeExternalMedia=1&asyncAugmentMetadata=1&asyncCheckFiles=1&asyncRefreshAnalysis=1' - : '' + `/library/metadata/${key}${ + options.includeChildren + ? '?includeChildren=1&includeExternalMedia=1&asyncAugmentMetadata=1&asyncCheckFiles=1&asyncRefreshAnalysis=1' + : '' }`, ); if (response) { @@ -262,6 +255,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -285,31 +279,26 @@ export class PlexApiService { this.logger.warn( "Outbound call to discover.provider.plex.tv failed. Couldn't fetch userState", ); + this.logger.debug(err); return undefined; } } - public async getUserDataFromPlexTv( - ): Promise { + public async getUserDataFromPlexTv(): Promise { try { const response = await this.plexTvClient.getUsers(); return response.MediaContainer.User; } catch (err) { - this.logger.warn( - "Outbound call to plex.tv failed. Couldn't fetch users", - ); + this.logger.warn("Outbound call to plex.tv failed. Couldn't fetch users"); + this.logger.debug(err); return undefined; } } public async getChildrenMetadata(key: string): Promise { try { - const response = await this.plexClient.query({ + const response = await this.plexClient.queryAll({ uri: `/library/metadata/${key}/children`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, }); return response.MediaContainer.Metadata; @@ -317,6 +306,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -328,20 +318,17 @@ export class PlexApiService { }, ): Promise { try { - const response = await this.plexClient.query({ + const response = await this.plexClient.queryAll({ uri: `/library/sections/${id}/all?sort=addedAt%3Adesc&addedAt>>=${Math.floor( options.addedAt / 1000, )}`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `500`, - }, }); return response.MediaContainer.Metadata as PlexLibraryItem[]; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -349,30 +336,23 @@ export class PlexApiService { public async getWatchHistory(itemId: string): Promise { try { const response: PlexLibraryResponse = - await this.plexClient.query({ + await this.plexClient.queryAll({ uri: `/status/sessions/history/all?sort=viewedAt:desc&metadataItemID=${itemId}`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, }); return response.MediaContainer.Metadata as PlexSeenBy[]; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } public async getCollections(libraryId: string): Promise { try { - const response = await this.plexClient.query({ + const response = await this.plexClient.queryAll({ uri: `/library/sections/${libraryId}/collections?`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, }); const collection: PlexCollection[] = response.MediaContainer .Metadata as PlexCollection[]; @@ -382,6 +362,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -395,12 +376,8 @@ export class PlexApiService { try { const filteredItems: PlexPlaylist[] = []; - const response = await this.plexClient.query({ + const response = await this.plexClient.queryAll({ uri: `/playlists?playlistType=video&includeCollections=1&includeExternalMedia=1&includeAdvanced=1&includeMeta=1`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, }); const items = response.MediaContainer.Metadata @@ -413,10 +390,10 @@ export class PlexApiService { }); const filteredForRatingKey = ( - itemResp.MediaContainer.Metadata as PlexLibraryItem[] - ).filter((i) => i.ratingKey === libraryId); + itemResp?.MediaContainer?.Metadata as PlexLibraryItem[] + )?.filter((i) => i.ratingKey === libraryId); - if (filteredForRatingKey.length > 0) { + if (filteredForRatingKey && filteredForRatingKey.length > 0) { filteredItems.push(item); } } @@ -426,6 +403,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -444,6 +422,7 @@ export class PlexApiService { errorMessage: e.message, plexId, }); + this.logger.debug(e); } } @@ -460,6 +439,7 @@ export class PlexApiService { return collection; } catch (err) { this.logger.warn(`Couldn't find collection with id ${+collectionId}`); + this.logger.debug(err); return undefined; } } @@ -467,9 +447,11 @@ export class PlexApiService { public async createCollection(params: CreateUpdateCollection) { try { const response = await this.plexClient.postQuery({ - uri: `/library/collections?type=${params.type - }&title=${encodeURIComponent(params.title)}§ionId=${params.libraryId - }`, + uri: `/library/collections?type=${ + params.type + }&title=${encodeURIComponent(params.title)}§ionId=${ + params.libraryId + }`, }); const collection: PlexCollection = response.MediaContainer .Metadata[0] as PlexCollection; @@ -482,6 +464,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -489,10 +472,11 @@ export class PlexApiService { public async updateCollection(body: CreateUpdateCollection) { try { await this.plexClient.putQuery({ - uri: `/library/sections/${body.libraryId}/all?type=18&id=${body.collectionId - }&title.value=${encodeURIComponent( - body.title, - )}&summary.value=${encodeURIComponent(body.summary)}`, + uri: `/library/sections/${body.libraryId}/all?type=18&id=${ + body.collectionId + }&title.value=${encodeURIComponent( + body.title, + )}&summary.value=${encodeURIComponent(body.summary)}`, // &titleSort.value=&summary.value=&contentRating.value=&title.locked=1&titleSort.locked=1&contentRating.locked=1`, }); return await this.getCollection(+body.collectionId); @@ -500,6 +484,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -512,6 +497,7 @@ export class PlexApiService { uri: `/library/collections/${collectionId}`, }); } catch (err) { + this.logger.debug(err); return { status: 'NOK', code: 0, @@ -531,18 +517,15 @@ export class PlexApiService { ): Promise { try { const response: PlexLibraryResponse = - await this.plexClient.query({ + await this.plexClient.queryAll({ uri: `/library/collections/${collectionId}/children`, - extraHeaders: { - 'X-Plex-Container-Start': `0`, - 'X-Plex-Container-Size': `1000`, - }, }); return response.MediaContainer.Metadata as PlexLibraryItem[]; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -559,6 +542,7 @@ export class PlexApiService { }); return response.MediaContainer.Metadata[0] as PlexCollection; } catch (e) { + this.logger.debug(e); return { status: 'NOK', code: 0, @@ -581,6 +565,7 @@ export class PlexApiService { message: `successfully deleted child with id ${childId}`, } as BasicResponseDto; } catch (e) { + this.logger.debug(e); return { status: 'NOK', code: 0, @@ -594,14 +579,16 @@ export class PlexApiService { ): Promise { try { const response: PlexHubResponse = await this.plexClient.postQuery({ - uri: `/hubs/sections/${params.libraryId}/manage?metadataItemId=${params.collectionId - }&promotedToRecommended=${+params.recommended}&promotedToOwnHome=${+params.ownHome}&promotedToSharedHome=${+params.sharedHome}`, + uri: `/hubs/sections/${params.libraryId}/manage?metadataItemId=${ + params.collectionId + }&promotedToRecommended=${+params.recommended}&promotedToOwnHome=${+params.ownHome}&promotedToSharedHome=${+params.sharedHome}`, }); return response.MediaContainer.Hub[0] as PlexHub; } catch (err) { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } @@ -700,12 +687,12 @@ export class PlexApiService { // transform & add eps data ? handleMedia.push( - ...data.map((el) => { - return { - plexId: +el.ratingKey, - }; - }), - ) + ...data.map((el) => { + return { + plexId: +el.ratingKey, + }; + }), + ) : undefined; break; case EPlexDataType.EPISODES: @@ -738,12 +725,12 @@ export class PlexApiService { // transform & add eps eps ? handleMedia.push( - ...eps.map((el) => { - return { - plexId: +el.ratingKey, - }; - }), - ) + ...eps.map((el) => { + return { + plexId: +el.ratingKey, + }; + }), + ) : undefined; } break; @@ -770,6 +757,7 @@ export class PlexApiService { this.logger.warn( 'Plex api communication failure.. Is the application running?', ); + this.logger.debug(err); return undefined; } } diff --git a/server/src/modules/api/servarr-api/helpers/radarr.helper.ts b/server/src/modules/api/servarr-api/helpers/radarr.helper.ts index 636da16f..26eb02af 100644 --- a/server/src/modules/api/servarr-api/helpers/radarr.helper.ts +++ b/server/src/modules/api/servarr-api/helpers/radarr.helper.ts @@ -20,7 +20,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { return response; } catch (e) { - this.logger.error(`Failed to retrieve movies`); + this.logger.warn(`Failed to retrieve movies`); this.logger.debug(`Failed to retrieve movies: ${e.message}`); } }; @@ -30,7 +30,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { const response = await this.get(`/movie/${id}`); return response; } catch (e) { - this.logger.error(`[Radarr] Failed to retrieve movie with id ${id}`); + this.logger.warn(`[Radarr] Failed to retrieve movie with id ${id}`); this.logger.debug(`[Radarr] Failed to retrieve movie: ${e.message}`); } }; @@ -49,7 +49,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { return response[0]; } catch (e) { - this.logger.error(`Error retrieving movie by TMDb ID ${id}`); + this.logger.warn(`Error retrieving movie by TMDb ID ${id}`); this.logger.debug(e); } } @@ -97,7 +97,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { return response.data; } else { - this.logger.error('Failed to update existing movie in Radarr.'); + this.logger.warn('Failed to update existing movie in Radarr.'); } } @@ -127,13 +127,14 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { if (response.data.id) { this.logger.log('Radarr accepted request'); } else { - this.logger.error('Failed to add movie to Radarr'); + this.logger.warn('Failed to add movie to Radarr'); } return response.data; } catch (e) { - this.logger.error( + this.logger.warn( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', ); + this.logger.debug(e); } }; @@ -143,9 +144,10 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { try { await this.runCommand('MoviesSearch', { movieIds: [movieId] }); } catch (e) { - this.logger.error( + this.logger.warn( 'Something went wrong while executing Radarr movie search.', ); + this.logger.debug(e); } } @@ -160,6 +162,7 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { ); } catch (e) { this.logger.log("Couldn't delete movie. Does it exist in radarr?"); + this.logger.debug(e); } } @@ -179,17 +182,21 @@ export class RadarrApi extends ServarrApi<{ movieId: number }> { } } catch (e) { this.logger.warn("Couldn't unmonitor movie. Does it exist in radarr?"); + this.logger.debug(e); } } public async info(): Promise { try { - const info: RadarrInfo = (await this.axios.get(`system/status`, { - signal: AbortSignal.timeout(10000), // aborts request after 10 seconds - })).data; + const info: RadarrInfo = ( + await this.axios.get(`system/status`, { + signal: AbortSignal.timeout(10000), // aborts request after 10 seconds + }) + ).data; return info ? info : null; } catch (e) { this.logger.warn("Couldn't fetch Radarr info.. Is Radarr up?"); + this.logger.debug(e); return null; } } diff --git a/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts b/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts index 79aea854..528ec17b 100644 --- a/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts +++ b/server/src/modules/api/servarr-api/helpers/sonarr.helper.ts @@ -25,7 +25,8 @@ export class SonarrApi extends ServarrApi<{ return response; } catch (e) { - this.logger.error(`[Sonarr] Failed to retrieve series: ${e.message}`); + this.logger.warn(`[Sonarr] Failed to retrieve series: ${e.message}`); + this.logger.debug(e); } } @@ -36,15 +37,17 @@ export class SonarrApi extends ServarrApi<{ ): Promise { try { const response = await this.get( - `/episode?seriesId=${seriesID}${seasonNumber ? `&seasonNumber=${seasonNumber}` : '' + `/episode?seriesId=${seriesID}${ + seasonNumber ? `&seasonNumber=${seasonNumber}` : '' }${episodeIds ? `&episodeIds=${episodeIds}` : ''}`, ); return response.filter((el) => episodeIds.includes(el.episodeNumber)); } catch (e) { - this.logger.error( + this.logger.warn( `[Sonarr] Failed to retrieve show ${seriesID}'s episodes ${episodeIds}: ${e.message}`, ); + this.logger.debug(e); } } public async getEpisodeFile(episodeFileId: number): Promise { @@ -55,10 +58,11 @@ export class SonarrApi extends ServarrApi<{ return response; } catch (e) { - this.logger.error( + this.logger.warn( `[Sonarr] Failed to retrieve episode file id ${episodeFileId}`, e.message, ); + this.logger.debug(e); } } @@ -71,17 +75,17 @@ export class SonarrApi extends ServarrApi<{ }); if (!response[0]) { - this.logger.error(`Series not found`); + this.logger.warn(`Series not found`); } return response; } catch (e) { - this.logger.error('Error retrieving series by series title', { + this.logger.warn('Error retrieving series by series title', { label: 'Sonarr API', errorMessage: e.message, title, }); - this.logger.error(`Series not found`); + this.logger.debug(e); } } @@ -102,6 +106,7 @@ export class SonarrApi extends ServarrApi<{ return response[0]; } catch (e) { this.logger.warn(`Error retrieving show by tvdb ID ${id}. ${e.message}`); + this.logger.debug(e); } } @@ -134,11 +139,11 @@ export class SonarrApi extends ServarrApi<{ return newSeriesResponse.data; } else { - this.logger.error('Failed to update series in Sonarr', { + this.logger.warn('Failed to update series in Sonarr', { label: 'Sonarr', options, }); - this.logger.error(`Failed to update series in Sonarr`); + this.logger.warn(`Failed to update series in Sonarr`); } } @@ -176,16 +181,16 @@ export class SonarrApi extends ServarrApi<{ movie: createdSeriesResponse.data, }); } else { - this.logger.error('Failed to add movie to Sonarr', { + this.logger.warn('Failed to add movie to Sonarr', { label: 'Sonarr', options, }); - this.logger.error(`Failed to add series to Sonarr`); + this.logger.warn(`Failed to add series to Sonarr`); } return createdSeriesResponse.data; } catch (e) { - this.logger.error( + this.logger.warn( 'Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', @@ -194,7 +199,7 @@ export class SonarrApi extends ServarrApi<{ response: e?.response?.data, }, ); - this.logger.error(`Failed to add series`); + this.logger.debug(e); } } @@ -208,14 +213,14 @@ export class SonarrApi extends ServarrApi<{ return data; } catch (e) { - this.logger.error( + this.logger.warn( 'Something went wrong while retrieving Sonarr language profiles.', { label: 'Sonarr API', errorMessage: e.message, }, ); - this.logger.error(`Failed to get language profiles`); + this.logger.debug(e); } } @@ -236,6 +241,7 @@ export class SonarrApi extends ServarrApi<{ seriesId, }, ); + this.logger.debug(e); } } @@ -255,6 +261,7 @@ export class SonarrApi extends ServarrApi<{ errorMessage: e.message, seriesId, }); + this.logger.debug(e); } } @@ -265,7 +272,8 @@ export class SonarrApi extends ServarrApi<{ deleteFiles = true, ) { this.logger.log( - `${!deleteFiles ? 'Unmonitoring' : 'Deleting'} ${episodeIds.length + `${!deleteFiles ? 'Unmonitoring' : 'Deleting'} ${ + episodeIds.length } episode(s) from show with ID ${seriesId} from Sonarr.`, ); try { @@ -291,6 +299,7 @@ export class SonarrApi extends ServarrApi<{ errorMessage: e.message, seriesId, }); + this.logger.debug(e); } } @@ -344,9 +353,11 @@ export class SonarrApi extends ServarrApi<{ seriesId, type, }); + this.logger.debug(e); } this.logger.log( - `Unmonitored season(s) ${typeof type === 'number' ? type : '' + `Unmonitored season(s) ${ + typeof type === 'number' ? type : '' } from Sonarr show with ID ${seriesId}`, ); } @@ -378,12 +389,15 @@ export class SonarrApi extends ServarrApi<{ public async info(): Promise { try { - const info: SonarrInfo = (await this.axios.get(`system/status`, { - signal: AbortSignal.timeout(10000), // aborts request after 10 seconds - })).data; + const info: SonarrInfo = ( + await this.axios.get(`system/status`, { + signal: AbortSignal.timeout(10000), // aborts request after 10 seconds + }) + ).data; return info ? info : null; } catch (e) { this.logger.warn("Couldn't fetch Sonarr info.. Is Sonarr up?"); + this.logger.debug(e); return null; } } diff --git a/server/src/modules/api/tmdb-api/tmdb-id.service.ts b/server/src/modules/api/tmdb-api/tmdb-id.service.ts index 301b90a7..d9bc3f2f 100644 --- a/server/src/modules/api/tmdb-api/tmdb-id.service.ts +++ b/server/src/modules/api/tmdb-api/tmdb-id.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { warn } from 'console'; import { PlexMetadata } from '../../../modules/api/plex-api/interfaces/media.interface'; import { PlexApiService } from '../../../modules/api/plex-api/plex-api.service'; @@ -10,7 +10,7 @@ export class TmdbIdService { constructor( private readonly tmdbApi: TmdbApiService, private readonly plexApi: PlexApiService, - ) { } + ) {} async getTmdbIdFromPlexRatingKey( ratingKey: string, ): Promise<{ type: 'movie' | 'tv'; id: number | undefined }> { @@ -20,18 +20,21 @@ export class TmdbIdService { // fetch show in case of season / episode libItem = libItem.grandparentRatingKey ? await this.plexApi.getMetadata( - libItem.grandparentRatingKey.toString(), - ) + libItem.grandparentRatingKey.toString(), + ) : libItem.parentRatingKey - ? await this.plexApi.getMetadata(libItem.parentRatingKey.toString()) - : libItem; + ? await this.plexApi.getMetadata(libItem.parentRatingKey.toString()) + : libItem; return this.getTmdbIdFromPlexData(libItem); } else { - warn(`[TMDb] Failed to fetch metadata of Plex rating key : ${ratingKey}`); + warn( + `[TMDb] Failed to fetch metadata of Plex rating key : ${ratingKey}`, + ); } } catch (e) { warn(`[TMDb] Failed to fetch id : ${e.message}`); + Logger.debug(e); return undefined; } } @@ -89,6 +92,7 @@ export class TmdbIdService { }; } catch (e) { warn(`[TMDb] Failed to fetch id : ${e.message}`); + Logger.debug(e); return undefined; } } diff --git a/server/src/modules/api/tmdb-api/tmdb.service.ts b/server/src/modules/api/tmdb-api/tmdb.service.ts index 08373314..4c6edd18 100644 --- a/server/src/modules/api/tmdb-api/tmdb.service.ts +++ b/server/src/modules/api/tmdb-api/tmdb.service.ts @@ -64,6 +64,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { + this.logger.debug(e); return { page: 1, results: [], @@ -88,6 +89,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch person details: ${e.message}`); + this.logger.debug(e); } }; @@ -135,6 +137,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch movie details: ${e.message}`); + this.logger.debug(e); } }; @@ -161,6 +164,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch TV show details: ${e.message}`); + this.logger.debug(e); } }; @@ -180,6 +184,7 @@ export class TmdbApiService extends ExternalApiService { } } catch (e) { warn(`[TMDb] Failed to fetch image path: ${e.message}`); + this.logger.debug(e); } }; @@ -206,6 +211,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch TV show details: ${e.message}`); + this.logger.debug(e); } }; @@ -232,6 +238,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch discover movies: ${e.message}`); + this.logger.debug(e); } } @@ -258,6 +265,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch discover movies: ${e.message}`); + this.logger.debug(e); } } @@ -284,6 +292,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch movies by keyword: ${e.message}`); + this.logger.debug(e); } } @@ -310,6 +319,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch TV recommendations: ${e.message}`); + this.logger.debug(e); } } @@ -333,6 +343,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch TV similar: ${e.message}`); + this.logger.debug(e); } } @@ -366,6 +377,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch discover movies: ${e.message}`); + this.logger.debug(e); } }; @@ -399,6 +411,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch discover TV: ${e.message}`); + this.logger.debug(e); } }; @@ -425,6 +438,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch upcoming movies: ${e.message}`); + this.logger.debug(e); } }; @@ -452,6 +466,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch all trending: ${e.message}`); + this.logger.debug(e); } }; @@ -475,6 +490,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch all trending: ${e.message}`); + this.logger.debug(e); } }; @@ -498,6 +514,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch all trending: ${e.message}`); + this.logger.debug(e); } }; @@ -529,6 +546,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to find by external ID: ${e.message}`); + this.logger.debug(e); } } @@ -557,6 +575,7 @@ export class TmdbApiService extends ExternalApiService { warn('[TMDb] Failed to find a title with the provided IMDB id'); } catch (e) { warn(`[TMDb] Failed to get movie by external imdb ID: ${e.message}`); + this.logger.debug(e); } } @@ -587,6 +606,7 @@ export class TmdbApiService extends ExternalApiService { warn( `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}`, ); + this.logger.debug(e); } } @@ -610,6 +630,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch collection: ${e.message}`); + this.logger.debug(e); } } @@ -626,6 +647,7 @@ export class TmdbApiService extends ExternalApiService { return regions; } catch (e) { warn(`[TMDb] Failed to fetch countries: ${e.message}`); + this.logger.debug(e); } } @@ -642,6 +664,7 @@ export class TmdbApiService extends ExternalApiService { return languages; } catch (e) { warn(`[TMDb] Failed to fetch langauges: ${e.message}`); + this.logger.debug(e); } } @@ -654,6 +677,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch movie studio: ${e.message}`); + this.logger.debug(e); } } @@ -664,6 +688,7 @@ export class TmdbApiService extends ExternalApiService { return data; } catch (e) { warn(`[TMDb] Failed to fetch TV network: ${e.message}`); + this.logger.debug(e); } } @@ -715,6 +740,7 @@ export class TmdbApiService extends ExternalApiService { return movieGenres; } catch (e) { warn(`[TMDb] Failed to fetch movie genres: ${e.message}`); + this.logger.debug(e); } } @@ -766,6 +792,7 @@ export class TmdbApiService extends ExternalApiService { return tvGenres; } catch (e) { warn(`[TMDb] Failed to fetch TV genres: ${e.message}`); + this.logger.debug(e); } } } diff --git a/server/src/modules/collections/collections.service.ts b/server/src/modules/collections/collections.service.ts index 1d7eead4..2e1b6c26 100644 --- a/server/src/modules/collections/collections.service.ts +++ b/server/src/modules/collections/collections.service.ts @@ -729,6 +729,7 @@ export class CollectionsService { this.logger.warn( 'An error occurred while performing collection actions.', ); + this.logger.debug(err); return undefined; } } @@ -770,16 +771,18 @@ export class CollectionsService { ]) .execute() ).generatedMaps[0] as addCollectionDbResponse; - } catch (_err) { + } catch (err) { // Log error this.infoLogger( `Something went wrong creating the collection in the Database..`, ); + this.logger.debug(err); } } catch (err) { this.logger.warn( 'An error occurred while performing collection actions.', ); + this.logger.debug(err); return undefined; } } @@ -794,14 +797,15 @@ export class CollectionsService { await this.collectionRepo.delete(collection.id); return { status: 'OK', code: 1, message: 'Success' }; - } catch (_err) { + } catch (err) { this.infoLogger( `Something went wrong deleting the collection from the Database..`, ); - this.logger.warn(_err); + this.logger.debug(err); return { status: 'NOK', code: 0, message: 'Removing from DB failed' }; } } catch (err) { + this.logger.debug(err); return { status: 'NOK', code: 0, message: 'Removing from DB failed' }; } } @@ -821,6 +825,7 @@ export class CollectionsService { this.logger.warn( 'An error occurred while performing collection actions.', ); + this.logger.debug(err); return undefined; } } @@ -842,6 +847,8 @@ export class CollectionsService { this.logger.warn( 'An error occurred while searching for a specific Plex collection.', ); + this.logger.debug(err); + return undefined; } } @@ -853,67 +860,11 @@ export class CollectionsService { this.logger.warn( 'An error occurred while searching for a specific Plex collection.', ); + this.logger.debug(err); return undefined; } } - // TODO: verwijderen als effectief niet nodig - // async syncCollectionMediaChildren( - // collectionDbId: number, - // collectionMediaChildren: [{ parent: number; child: number }], - // ): Promise { - // const collection = await this.collectionRepo.findOne({ - // where: { id: collectionDbId }, - // }); - // if (collectionMediaChildren) { - // if (collection) { - // // add missing children - // collectionMediaChildren.forEach((el) => { - // const media = collection.collectionMedia.find( - // (media) => media.plexId === el.parent, - // ); - // if (media) { - // if ( - // !media.collectionMediaChild.find( - // (child) => child.plexId === el.child, - // ) - // ) { - // this.collectionMediaChildRepo.save({ - // plexId: el.child, - // collectionMediaId: media.id, - // }); - // } - // } else { - // this.infoLogger( - // `Couldn't find media with plexId ${el.parent}, this means the child with plexId ${el.child} could not be synced`, - // ); - // } - // }); - - // // remove deleted children - // collection.collectionMedia.forEach((media) => { - // media.collectionMediaChild.forEach((child) => { - // if ( - // !collectionMediaChildren.find( - // (el) => el.parent === media.plexId && el.child === child.plexId, - // ) - // ) { - // this.collectionMediaChildRepo.delete(child.id); - // } - // }); - // }); - - // // update & return collection - // return await this.collectionRepo.findOne({ - // where: { id: collectionDbId }, - // }); - // } else { - // this.infoLogger(`Couldn't find collection with id ${collectionDbId}`); - // } - // } - // return collection; - // } - private infoLogger(message: string) { this.logger.log(message); } diff --git a/server/src/modules/rules/constants/rules.constants.ts b/server/src/modules/rules/constants/rules.constants.ts index 1b2233ec..63993cbf 100644 --- a/server/src/modules/rules/constants/rules.constants.ts +++ b/server/src/modules/rules/constants/rules.constants.ts @@ -124,8 +124,8 @@ export class RuleConstants { } as Property, { id: 3, - name: 'rating', - humanName: 'Rating', + name: 'rating_user', + humanName: 'User rating (scale 1-10)', mediaType: MediaType.BOTH, type: RuleType.NUMBER, } as Property, @@ -265,6 +265,20 @@ export class RuleConstants { mediaType: MediaType.BOTH, type: RuleType.TEXT, } as Property, + { + id: 22, + name: 'rating_critics', + humanName: 'Critics rating (scale 1-10)', + mediaType: MediaType.BOTH, + type: RuleType.NUMBER, + } as Property, + { + id: 23, + name: 'rating_audience', + humanName: 'Audience rating (scale 1-10)', + mediaType: MediaType.BOTH, + type: RuleType.NUMBER, + } as Property, ], }, { diff --git a/server/src/modules/rules/getter/plex-getter.service.ts b/server/src/modules/rules/getter/plex-getter.service.ts index aeffb466..cf1c440e 100644 --- a/server/src/modules/rules/getter/plex-getter.service.ts +++ b/server/src/modules/rules/getter/plex-getter.service.ts @@ -59,9 +59,15 @@ export class PlexGetterService { ? new Date(libItem.originallyAvailableAt) : null; } - case 'rating': { + case 'rating_critics': { + return libItem.rating ? +libItem.rating : 0; + } + case 'rating_audience': { return libItem.audienceRating ? +libItem.audienceRating : 0; } + case 'rating_user': { + return libItem.userRating ? +libItem.userRating : 0; + } case 'people': { return libItem.Role ? libItem.Role.map((el) => el.tag) : null; } @@ -73,18 +79,18 @@ export class PlexGetterService { // fetch metadata because collections in plexLibrary object are wrong return libItem.Collection ? ( - await this.plexApi.getMetadata(libItem.ratingKey) - ).Collection.filter( - (el) => - el.tag.toLowerCase().trim() !== - (ruleGroup.manualCollectionName - ? ruleGroup.manualCollectionName - : ruleGroup.name - ) - .toLowerCase() - .trim(), - ).length - : null; + await this.plexApi.getMetadata(libItem.ratingKey) + ).Collection.filter( + (el) => + el.tag.toLowerCase().trim() !== + (ruleGroup.manualCollectionName + ? ruleGroup.manualCollectionName + : ruleGroup.name + ) + .toLowerCase() + .trim(), + ).length + : 0; } case 'playlists': { if (libItem.type !== 'episode' && libItem.type !== 'movie') { @@ -195,13 +201,13 @@ export class PlexGetterService { const item = libItem.type === 'episode' ? ((await this.plexApi.getMetadata( - libItem.grandparentRatingKey, - )) as unknown as PlexLibraryItem) + libItem.grandparentRatingKey, + )) as unknown as PlexLibraryItem) : libItem.type === 'season' - ? ((await this.plexApi.getMetadata( + ? ((await this.plexApi.getMetadata( libItem.parentRatingKey, )) as unknown as PlexLibraryItem) - : libItem; + : libItem; return item.Genre ? item.Genre.map((el) => el.tag) : null; } case 'sw_allEpisodesSeenBy': { @@ -340,12 +346,15 @@ export class PlexGetterService { case 'sw_lastEpisodeAddedAt': { const seasons = libItem.type !== 'season' - ? await this.plexApi.getChildrenMetadata(libItem.ratingKey) + ? ( + await this.plexApi.getChildrenMetadata(libItem.ratingKey) + ).sort((a, b) => a.index - b.index) : [libItem]; const lastEpDate = await this.plexApi .getChildrenMetadata(seasons[seasons.length - 1].ratingKey) .then((eps) => { + eps.sort((a, b) => a.index - b.index); return eps[eps.length - 1]?.addedAt ? +eps[eps.length - 1].addedAt : null; @@ -367,7 +376,7 @@ export class PlexGetterService { const plexTvUsers = await this.plexApi.getUserDataFromPlexTv(); return (await this.plexApi.getUsers()).map((el) => { - const plextv = plexTvUsers?.find(tvEl => tvEl.$?.id == el.id) + const plextv = plexTvUsers?.find((tvEl) => tvEl.$?.id == el.id); // use the username from plex.tv if available, since Overseerr also does this if (plextv && plextv.$ && plextv.$.username) { @@ -375,6 +384,5 @@ export class PlexGetterService { } return { plexId: el.id, username: el.name } as PlexUser; }); - } } diff --git a/server/src/modules/rules/rule-maintenance.service.ts b/server/src/modules/rules/rule-maintenance.service.ts index 943d730e..65a3e5b8 100644 --- a/server/src/modules/rules/rule-maintenance.service.ts +++ b/server/src/modules/rules/rule-maintenance.service.ts @@ -3,6 +3,9 @@ import { TasksService } from '../tasks/tasks.service'; import { SettingsService } from '../settings/settings.service'; import { RulesService } from './rules.service'; import { PlexApiService } from '../api/plex-api/plex-api.service'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Collection } from '../collections/entities/collection.entities'; @Injectable() export class RuleMaintenanceService implements OnApplicationBootstrap { @@ -13,6 +16,8 @@ export class RuleMaintenanceService implements OnApplicationBootstrap { private readonly taskService: TasksService, private readonly settings: SettingsService, private readonly rulesService: RulesService, + @InjectRepository(Collection) + private readonly collectionRepo: Repository, private readonly plexApi: PlexApiService, ) {} @@ -20,7 +25,7 @@ export class RuleMaintenanceService implements OnApplicationBootstrap { this.jobCreationAttempts++; const state = this.taskService.createJob( 'RuleMaintenance', - '30 3 * * 2', + '20 4 * * */1', this.execute.bind(this), ); if (state.code === 0) { @@ -45,6 +50,12 @@ export class RuleMaintenanceService implements OnApplicationBootstrap { if (appStatus) { // remove media exclusions that are no longer available this.removeLeftoverExclusions(); + this.removeCollectionsWithoutRule(); + this.logger.log('Maintenance done'); + } else { + this.logger.error( + `Maintenance skipped, not all applications were reachable.`, + ); } } catch (e) { this.logger.error(`RuleMaintenance failed : ${e.message}`); @@ -64,4 +75,23 @@ export class RuleMaintenanceService implements OnApplicationBootstrap { } } } + + private async removeCollectionsWithoutRule() { + try { + const collections = await this.collectionRepo.find(); // get all collections + const rulegroups = await this.rulesService.getRuleGroups(); + + for (const collection of collections) { + if ( + !rulegroups.find( + (rulegroup) => rulegroup.collection?.id === collection.id, + ) + ) { + await this.collectionRepo.delete({ id: collection.id }); + } + } + } catch (err) { + this.logger.warn("Couldn't remove collection without rule: " + err); + } + } } diff --git a/server/src/modules/rules/rules.service.ts b/server/src/modules/rules/rules.service.ts index fea210da..ef9790ff 100644 --- a/server/src/modules/rules/rules.service.ts +++ b/server/src/modules/rules/rules.service.ts @@ -97,6 +97,7 @@ export class RulesService { .getMany(); } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -126,6 +127,7 @@ export class RulesService { return rulegroups as RulesDto[]; } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -137,6 +139,7 @@ export class RulesService { }); } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -159,7 +162,8 @@ export class RulesService { ); return this.createReturnStatus(true, 'Success'); } catch (err) { - this.logger.error(err); + this.logger.warn('Rulegroup deletion failed'); + this.logger.debug(err); return this.createReturnStatus(false, 'Delete Failed'); } } @@ -241,6 +245,7 @@ export class RulesService { } } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -304,8 +309,9 @@ export class RulesService { return this.createReturnStatus(true, 'Success'); } catch (e) { this.logger.warn( - `Adding exclusion for Plex ID ${data.mediaId} and rulegroup ID ${data.ruleGroupId} failed with error : ${e}`, + `Adding exclusion for Plex ID ${data.mediaId} and rulegroup ID ${data.ruleGroupId} failed.`, ); + this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); } } @@ -315,9 +321,8 @@ export class RulesService { await this.exclusionRepo.delete(id); return this.createReturnStatus(true, 'Success'); } catch (e) { - this.logger.warn( - `Removing exclusion with ID ${id} failed with error : ${e}`, - ); + this.logger.warn(`Removing exclusion with ID ${id} failed.`); + this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); } } @@ -367,8 +372,9 @@ export class RulesService { return this.createReturnStatus(true, 'Success'); } catch (e) { this.logger.warn( - `Removing exclusion for media with ID ${data.mediaId} failed with error : ${e}`, + `Removing exclusion for media with ID ${data.mediaId} failed.`, ); + this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); } } @@ -393,9 +399,8 @@ export class RulesService { } return this.createReturnStatus(true, 'Success'); } catch (e) { - this.logger.warn( - `Removing all exclusions with plexId ${plexId} failed with error : ${e}`, - ); + this.logger.warn(`Removing all exclusions with plexId ${plexId} failed.`); + this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); } } @@ -433,6 +438,7 @@ export class RulesService { return []; } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -442,6 +448,7 @@ export class RulesService { return await this.exclusionRepo.find(); } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return []; } } @@ -490,7 +497,8 @@ export class RulesService { } else { return this.createReturnStatus(false, 'No second value found'); } - } catch { + } catch (e) { + this.logger.debug(e); return this.createReturnStatus(false, 'Unexpected error occurred'); } } @@ -528,6 +536,7 @@ export class RulesService { return groupId.identifiers[0].id; } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); return undefined; } } @@ -546,6 +555,7 @@ export class RulesService { this.logger.warn( `Rules - Loading community rules failed : ${e.message}`, ); + this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); }); } diff --git a/server/src/modules/settings/settings.service.ts b/server/src/modules/settings/settings.service.ts index 12a9a62c..22cb7f02 100644 --- a/server/src/modules/settings/settings.service.ts +++ b/server/src/modules/settings/settings.service.ts @@ -240,7 +240,9 @@ export class SettingsService implements SettingDto { return resp !== null && resp.version !== undefined ? { status: 'OK', code: 1, message: resp.version } : { status: 'NOK', code: 0, message: 'Failure' }; - } catch { + } catch (e) { + this.logger.debug(e); + return { status: 'NOK', code: 0, message: 'Failure' }; } } @@ -251,7 +253,8 @@ export class SettingsService implements SettingDto { return resp !== null && resp.version !== undefined ? { status: 'OK', code: 1, message: resp.version } : { status: 'NOK', code: 0, message: 'Failure' }; - } catch { + } catch (e) { + this.logger.debug(e); return { status: 'NOK', code: 0, message: 'Failure' }; } } @@ -280,7 +283,8 @@ export class SettingsService implements SettingDto { } else { return false; } - } catch { + } catch (e) { + this.logger.debug(e); return false; } } @@ -309,7 +313,8 @@ export class SettingsService implements SettingDto { return true; } return false; - } catch { + } catch (e) { + this.logger.debug(e); return false; } } diff --git a/server/src/modules/tasks/tasks.service.ts b/server/src/modules/tasks/tasks.service.ts index e2a324ce..24fbcb26 100644 --- a/server/src/modules/tasks/tasks.service.ts +++ b/server/src/modules/tasks/tasks.service.ts @@ -33,9 +33,11 @@ export class TasksService implements TaskScheduler { `Task ${name} created successfully`, ); } catch (e) { - this.logger.error( + this.logger.warn( `An error occurred while creating the ${name} task. This is normal on first boot.`, ); + this.logger.debug(e); + return this.status.createStatus( false, `An error occurred while creating the ${name} task`, @@ -63,9 +65,8 @@ export class TasksService implements TaskScheduler { `Task ${name} started successfully`, ); } catch (e) { - this.logger.error( - `An error occurred while starting the ${name} task: ${e}`, - ); + this.logger.error(`An error occurred while starting the ${name} task.`); + this.logger.debug(e); return this.status.createStatus( false, `An error occurred while starting the ${name} task`, @@ -82,9 +83,8 @@ export class TasksService implements TaskScheduler { `Task ${name} removed successfully`, ); } catch (e) { - this.logger.error( - `An error occurred while removing the ${name} task: ${e}`, - ); + this.logger.error(`An error occurred while removing the ${name} task.`); + this.logger.debug(e); return this.status.createStatus( false, `An error occurred while removing the ${name} task`,