diff --git a/server/src/database/migrations/1705399208460-Exclusions-add-type-field.ts b/server/src/database/migrations/1705399208460-Exclusions-add-type-field.ts new file mode 100644 index 00000000..ea955d46 --- /dev/null +++ b/server/src/database/migrations/1705399208460-Exclusions-add-type-field.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExclusionsAddTypeField1705399208460 implements MigrationInterface { + name = 'ExclusionsAddTypeField1705399208460'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE exclusion ADD COLUMN "type" INTEGER CHECK("type" IS NULL OR "type" IN (1, 2, 3, 4)) DEFAULT NULL', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE exclusion DROP "type"`); + } +} diff --git a/server/src/modules/collections/collections.controller.ts b/server/src/modules/collections/collections.controller.ts index 91ee597f..d3d44ade 100644 --- a/server/src/modules/collections/collections.controller.ts +++ b/server/src/modules/collections/collections.controller.ts @@ -153,4 +153,21 @@ export class CollectionsController { size: size, }); } + + @Get('/exclusions/:id/content/:page') + getExclusions( + @Param('id') id: number, + @Param('page', new ParseIntPipe()) page: number, + @Query('size') amount: number, + ) { + const size = amount ? amount : 25; + const offset = (page - 1) * size; + return this.collectionService.getCollectionExclusionsWithPlexDataAndhPaging( + id, + { + offset: offset, + size: size, + }, + ); + } } diff --git a/server/src/modules/collections/collections.module.ts b/server/src/modules/collections/collections.module.ts index 475fd146..a40ff672 100644 --- a/server/src/modules/collections/collections.module.ts +++ b/server/src/modules/collections/collections.module.ts @@ -11,11 +11,17 @@ import { OverseerrApiModule } from '../api/overseerr-api/overseerr-api.module'; import { ServarrApiModule } from '../api/servarr-api/servarr-api.module'; import { RuleGroup } from '../rules/entities/rule-group.entities'; import { TasksModule } from '../tasks/tasks.module'; +import { Exclusion } from '../rules/entities/exclusion.entities'; @Module({ imports: [ PlexApiModule, - TypeOrmModule.forFeature([Collection, CollectionMedia, RuleGroup]), + TypeOrmModule.forFeature([ + Collection, + CollectionMedia, + RuleGroup, + Exclusion, + ]), OverseerrApiModule, TmdbApiModule, ServarrApiModule, diff --git a/server/src/modules/collections/collections.service.ts b/server/src/modules/collections/collections.service.ts index 2e1b6c26..3a8003b2 100644 --- a/server/src/modules/collections/collections.service.ts +++ b/server/src/modules/collections/collections.service.ts @@ -23,6 +23,7 @@ import { } from './interfaces/collection-media.interface'; import { ICollection } from './interfaces/collection.interface'; import { EPlexDataType } from '../api/plex-api/enums/plex-data-type-enum'; +import { Exclusion } from '../rules/entities/exclusion.entities'; interface addCollectionDbResponse { id: number; @@ -41,6 +42,8 @@ export class CollectionsService { private readonly CollectionMediaRepo: Repository, @InjectRepository(RuleGroup) private readonly ruleGroupRepo: Repository, + @InjectRepository(Exclusion) + private readonly exclusionRepo: Repository, private readonly connection: Connection, private readonly plexApi: PlexApiService, private readonly tmdbApi: TmdbApiService, @@ -120,14 +123,68 @@ export class CollectionsService { } } + public async getCollectionExclusionsWithPlexDataAndhPaging( + id: number, + { offset = 0, size = 25 }: { offset?: number; size?: number } = {}, + ): Promise<{ totalSize: number; items: Exclusion[] }> { + try { + const rulegroup = await this.ruleGroupRepo.findOne({ + where: { + collectionId: id, + }, + }); + + const groupId = rulegroup.id; + + const queryBuilder = this.exclusionRepo.createQueryBuilder('exclusion'); + + queryBuilder + .where(`exclusion.ruleGroupId = ${groupId}`) + .orWhere(`exclusion.ruleGroupId is null`) + .andWhere(`exclusion.type = ${rulegroup.dataType}`) + .orderBy('id', 'DESC') + .skip(offset) + .take(size); + + const itemCount = await queryBuilder.getCount(); + let { entities } = await queryBuilder.getRawAndEntities(); + + entities = await Promise.all( + entities.map(async (el) => { + el.plexData = await this.plexApi.getMetadata(el.plexId.toString()); + if (el.plexData?.grandparentRatingKey) { + el.plexData.parentData = await this.plexApi.getMetadata( + el.plexData.grandparentRatingKey.toString(), + ); + } else if (el.plexData?.parentRatingKey) { + el.plexData.parentData = await this.plexApi.getMetadata( + el.plexData.parentRatingKey.toString(), + ); + } + return el; + }), + ); + + return { + totalSize: itemCount, + items: entities ?? [], + }; + } catch (err) { + this.logger.warn( + 'An error occurred while performing collection actions: ' + err, + ); + return undefined; + } + } + async getCollections(libraryId?: number, typeId?: 1 | 2 | 3 | 4) { try { const collections = await this.collectionRepo.find( libraryId ? { where: { libraryId: libraryId } } : typeId - ? { where: { type: typeId } } - : undefined, + ? { where: { type: typeId } } + : undefined, ); return await Promise.all( @@ -260,7 +317,9 @@ export class CollectionsService { const dbCollection = await this.collectionRepo.findOne({ where: { id: +collection.id }, }); + let plexColl: PlexCollection; + if (dbCollection?.plexId) { const collectionObj: CreateUpdateCollection = { libraryId: collection.libraryId.toString(), @@ -269,14 +328,36 @@ export class CollectionsService { collectionId: +dbCollection.plexId, summary: collection?.description, }; - plexColl = await this.plexApi.updateCollection(collectionObj); - await this.plexApi.UpdateCollectionSettings({ - libraryId: dbCollection.libraryId, - collectionId: dbCollection.plexId, - recommended: false, - ownHome: collection.visibleOnHome, - sharedHome: collection.visibleOnHome, - }); + + // is the type the same & is it an automatic collection, then update + if ( + collection.type === dbCollection.type && + !dbCollection.manualCollection && + !collection.manualCollection + ) { + plexColl = await this.plexApi.updateCollection(collectionObj); + await this.plexApi.UpdateCollectionSettings({ + libraryId: dbCollection.libraryId, + collectionId: dbCollection.plexId, + recommended: false, + ownHome: collection.visibleOnHome, + sharedHome: collection.visibleOnHome, + }); + } else { + // if the type changed, or the manual collection changed + if ( + collection.manualCollection !== dbCollection.manualCollection || + collection.type !== dbCollection.type || + collection.manualCollectionName !== + dbCollection.manualCollectionName + ) { + if (!dbCollection.manualCollection) { + // Don't remove the collections if it was a manual one + this.plexApi.deleteCollection(dbCollection.plexId.toString()); + } + dbCollection.plexId = null; + } + } } const dbResp: ICollection = await this.collectionRepo.save({ ...dbCollection, diff --git a/server/src/modules/rules/entities/exclusion.entities.ts b/server/src/modules/rules/entities/exclusion.entities.ts index 5d9753d5..644a35f5 100644 --- a/server/src/modules/rules/entities/exclusion.entities.ts +++ b/server/src/modules/rules/entities/exclusion.entities.ts @@ -1,3 +1,4 @@ +import { PlexMetadata } from 'src/modules/api/plex-api/interfaces/media.interface'; import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() @@ -13,4 +14,9 @@ export class Exclusion { @Column({ nullable: true }) parent: number; + + @Column({ nullable: true }) // nullable because old exclusions don't have the type. They'll be added by a maintenance task + type: 1 | 2 | 3 | 4 | undefined; + + plexData: PlexMetadata; // this will be added programatically } diff --git a/server/src/modules/rules/rules.controller.ts b/server/src/modules/rules/rules.controller.ts index 8b011bbd..fe608e6b 100644 --- a/server/src/modules/rules/rules.controller.ts +++ b/server/src/modules/rules/rules.controller.ts @@ -11,7 +11,7 @@ import { import { CommunityRule } from './dtos/communityRule.dto'; import { ExclusionAction, ExclusionContextDto } from './dtos/exclusion.dto'; import { RulesDto } from './dtos/rules.dto'; -import { RuleExecutorService } from './rule-executor.service'; +import { RuleExecutorService } from './tasks/rule-executor.service'; import { ReturnStatus, RulesService } from './rules.service'; @Controller('api/rules') @@ -101,8 +101,7 @@ export class RulesController { } @Put() async updateRule(@Body() body: RulesDto): Promise { - this.rulesService.deleteRuleGroup(body.id); - return await this.rulesService.setRules(body); + return await this.rulesService.updateRules(body); } @Post() async updateJob(@Body() body: { cron: string }): Promise { diff --git a/server/src/modules/rules/rules.module.ts b/server/src/modules/rules/rules.module.ts index 82ca50aa..503cb14f 100644 --- a/server/src/modules/rules/rules.module.ts +++ b/server/src/modules/rules/rules.module.ts @@ -4,7 +4,7 @@ import { RulesController } from './rules.controller'; import { PlexApiModule } from '../api/plex-api/plex-api.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Rules } from './entities/rules.entities'; -import { RuleExecutorService } from './rule-executor.service'; +import { RuleExecutorService } from './tasks/rule-executor.service'; import { RuleGroup } from './entities/rule-group.entities'; import { PlexGetterService } from './getter/plex-getter.service'; import { ValueGetterService } from './getter/getter.service'; @@ -21,10 +21,11 @@ import { CollectionMedia } from '../collections/entities/collection_media.entiti import { Exclusion } from './entities/exclusion.entities'; import { CommunityRuleKarma } from './entities/community-rule-karma.entities'; import { Settings } from '../settings/entities/settings.entities'; -import { RuleMaintenanceService } from './rule-maintenance.service'; +import { RuleMaintenanceService } from './tasks/rule-maintenance.service'; import { RuleYamlService } from './helpers/yaml.service'; import { RuleComparatorService } from './helpers/rule.comparator.service'; import { RuleConstanstService } from './constants/constants.service'; +import { ExclusionTypeCorrectorService } from './tasks/exclusion-corrector.service'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { RuleConstanstService } from './constants/constants.service'; RulesService, RuleExecutorService, RuleMaintenanceService, + ExclusionTypeCorrectorService, PlexGetterService, RadarrGetterService, SonarrGetterService, @@ -55,7 +57,7 @@ import { RuleConstanstService } from './constants/constants.service'; ValueGetterService, RuleYamlService, RuleComparatorService, - RuleConstanstService + RuleConstanstService, ], controllers: [RulesController], }) diff --git a/server/src/modules/rules/rules.service.ts b/server/src/modules/rules/rules.service.ts index f8c83ae8..2418971f 100644 --- a/server/src/modules/rules/rules.service.ts +++ b/server/src/modules/rules/rules.service.ts @@ -225,7 +225,7 @@ export class RulesService { }) ).dbCollection; // create group - const groupId = await this.createNewGroup( + const groupId = await this.createOrUpdateGroup( params.name, params.description, params.libraryId, @@ -267,6 +267,124 @@ export class RulesService { } } + async updateRules(params: RulesDto) { + try { + let state: ReturnStatus = this.createReturnStatus(true, 'Success'); + params.rules.forEach((rule) => { + if (state.code === 1) { + state = this.validateRule(rule); + } + }, this); + + if (state.code === 1) { + // get current group + const group = await this.ruleGroupRepository.findOne({ + where: { id: params.id }, + }); + + const dbCollection = await this.collectionService.getCollection( + group.collectionId, + ); + + // if datatype or manual collection settings changed then remove the collection media and specific exclusions. The Plex collection will be removed later by updateCollection() + if ( + group.dataType !== params.dataType || + params.collection.manualCollection !== + dbCollection.manualCollection || + params.collection.manualCollectionName !== + dbCollection.manualCollectionName + ) { + this.logger.log( + `A crucial setting of Rulegroup '${params.name}' was changed. Removed all media & specific exclusions`, + ); + await this.collectionMediaRepository.delete({ + collectionId: group.collectionId, + }); + await this.exclusionRepo.delete({ ruleGroupId: params.id }); + } + + // update the collection + const lib = (await this.plexApi.getLibraries()).find( + (el) => +el.key === +params.libraryId, + ); + + const collection = ( + await this.collectionService.updateCollection({ + id: group.id ? group.id : undefined, + libraryId: +params.libraryId, + type: + lib.type === 'movie' + ? EPlexDataType.MOVIES + : params.dataType !== undefined + ? params.dataType + : EPlexDataType.SHOWS, + title: params.name, + description: params.description, + arrAction: params.arrAction ? params.arrAction : 0, + isActive: params.isActive, + listExclusions: params.listExclusions + ? params.listExclusions + : false, + forceOverseerr: params.forceOverseerr + ? params.forceOverseerr + : false, + visibleOnHome: params.collection?.visibleOnHome, + deleteAfterDays: +params.collection?.deleteAfterDays, + manualCollection: params.collection?.manualCollection, + manualCollectionName: params.collection?.manualCollectionName, + }) + ).dbCollection; + + // update or create group + const groupId = await this.createOrUpdateGroup( + params.name, + params.description, + params.libraryId, + collection.id, + params.useRules !== undefined ? params.useRules : true, + params.isActive !== undefined ? params.isActive : true, + params.dataType !== undefined ? params.dataType : undefined, + group.id, + ); + + // remove previous rules + this.rulesRepository.delete({ + ruleGroupId: groupId, + }); + + // create rules + if (params.useRules) { + for (const rule of params.rules) { + const ruleJson = JSON.stringify(rule); + await this.rulesRepository.save([ + { + ruleJson: ruleJson, + ruleGroupId: groupId, + section: (rule as RuleDbDto).section, + }, + ]); + } + } else { + // empty rule if not using rules + await this.rulesRepository.save([ + { + ruleJson: JSON.stringify(''), + ruleGroupId: groupId, + section: 0, + }, + ]); + } + this.logger.log(`Successfully updated rulegroup '${params.name}'.`); + return state; + } else { + return state; + } + } catch (e) { + this.logger.warn(`Rules - Action failed : ${e.message}`); + this.logger.debug(e); + return undefined; + } + } async setExclusion(data: ExclusionContextDto) { let handleMedia: AddCollectionMedia[] = []; @@ -281,7 +399,7 @@ export class RulesService { group ? group.dataType : undefined, data.context ? data.context - : { type: group.libraryId, id: data.mediaId }, + : { type: group.dataType, id: data.mediaId }, { plexId: data.mediaId }, )) as unknown as AddCollectionMedia[]; data.ruleGroupId = group.id; @@ -300,6 +418,10 @@ export class RulesService { try { // add all items for (const media of handleMedia) { + const metaData = await this.plexApi.getMetadata( + media.plexId.toString(), + ); + const old = await this.exclusionRepo.findOne({ where: { plexId: media.plexId, @@ -319,14 +441,34 @@ export class RulesService { : { ruleGroupId: null }), // set parent parent: data.mediaId ? data.mediaId : null, + // set media type + type: + metaData.type === 'movie' + ? 1 + : metaData.type === 'show' + ? 2 + : metaData.type === 'season' + ? 3 + : metaData.type === 'episode' + ? 4 + : undefined, }, ]); + this.logger.log( + `Added ${ + data.ruleGroupId === undefined ? 'global ' : '' + }exclusion for media with id ${media.plexId} ${ + data.ruleGroupId !== undefined + ? `and rulegroup id ${data.ruleGroupId}` + : '' + } `, + ); } return this.createReturnStatus(true, 'Success'); } catch (e) { this.logger.warn( - `Adding exclusion for Plex ID ${data.mediaId} and rulegroup ID ${data.ruleGroupId} failed.`, + `Adding exclusion for Plex ID ${data.mediaId} and rulegroup id ${data.ruleGroupId} failed.`, ); this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); @@ -336,9 +478,10 @@ export class RulesService { async removeExclusion(id: number) { try { await this.exclusionRepo.delete(id); + this.logger.log(`Removed exclusion with id ${id}`); return this.createReturnStatus(true, 'Success'); } catch (e) { - this.logger.warn(`Removing exclusion with ID ${id} failed.`); + this.logger.warn(`Removing exclusion with id ${id} failed.`); this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); } @@ -355,7 +498,6 @@ export class RulesService { }); data.ruleGroupId = group.id; - // get media handleMedia = (await this.plexApi.getAllIdsForContextAction( group ? group.dataType : undefined, @@ -366,10 +508,6 @@ export class RulesService { )) as unknown as AddCollectionMedia[]; } else { // get type from metadata - // const metaData = await this.plexApi.getMetadata(data.mediaId.toString()); - // const type = - // metaData.type === 'movie' ? EPlexDataType.MOVIES : EPlexDataType.SHOWS; - handleMedia = (await this.plexApi.getAllIdsForContextAction( undefined, { type: data.context.type, id: data.context.id }, @@ -385,11 +523,20 @@ export class RulesService { ? { ruleGroupId: data.ruleGroupId } : {}), }); + this.logger.log( + `Removed ${ + data.ruleGroupId === undefined ? 'global ' : '' + }exclusion for media with id ${media.plexId} ${ + data.ruleGroupId !== undefined + ? `and rulegroup id ${data.ruleGroupId}` + : '' + } `, + ); } return this.createReturnStatus(true, 'Success'); } catch (e) { this.logger.warn( - `Removing exclusion for media with ID ${data.mediaId} failed.`, + `Removing exclusion for media with id ${data.mediaId} failed.`, ); this.logger.debug(e); return this.createReturnStatus(false, 'Failed'); @@ -524,7 +671,7 @@ export class RulesService { return { code: succes ? 1 : 0, result: result }; } - private async createNewGroup( + private async createOrUpdateGroup( name: string, description: string, libraryId: number, @@ -532,25 +679,35 @@ export class RulesService { useRules = true, isActive = true, dataType = undefined, + id?: number, ): Promise { try { - const groupId = await this.connection - .createQueryBuilder() - .insert() - .into(RuleGroup) - .values([ - { - name: name, - description: description, - libraryId: +libraryId, - collectionId: +collectionId, - isActive: isActive, - useRules: useRules, - dataType: dataType, - }, - ]) - .execute(); - return groupId.identifiers[0].id; + const values = { + name: name, + description: description, + libraryId: +libraryId, + collectionId: +collectionId, + isActive: isActive, + useRules: useRules, + dataType: dataType, + }; + const connection = this.connection.createQueryBuilder(); + + if (!id) { + const groupId = await connection + .insert() + .into(RuleGroup) + .values(values) + .execute(); + return groupId.identifiers[0].id; + } else { + const groupId = await connection + .update(RuleGroup) + .set(values) + .where({ id: id }) + .execute(); + return id; + } } catch (e) { this.logger.warn(`Rules - Action failed : ${e.message}`); this.logger.debug(e); diff --git a/server/src/modules/rules/tasks/exclusion-corrector.service.ts b/server/src/modules/rules/tasks/exclusion-corrector.service.ts new file mode 100644 index 00000000..4779875a --- /dev/null +++ b/server/src/modules/rules/tasks/exclusion-corrector.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PlexApiService } from '../../api/plex-api/plex-api.service'; +import { Exclusion } from '../entities/exclusion.entities'; +import { SettingsService } from '../../settings/settings.service'; +import { Timeout } from '@nestjs/schedule'; +import { RulesService } from '../rules.service'; + +@Injectable() +export class ExclusionTypeCorrectorService implements OnModuleInit { + private readonly logger = new Logger(ExclusionTypeCorrectorService.name); + + constructor( + private readonly plexApi: PlexApiService, + private readonly settings: SettingsService, + private readonly rulesService: RulesService, + @InjectRepository(Exclusion) + private readonly exclusionRepo: Repository, + ) {} + onModuleInit() { + // nothing + } + + @Timeout(5000) + private async execute() { + try { + const appStatus = await this.settings.testPlex(); + + if (appStatus) { + // remove media exclusions that are no longer available + this.correctExclusionTypes(); + } + } catch (e) { + this.logger.warn(`Exclusion type corrections failed : ${e.message}`); + } + } + + private async correctExclusionTypes() { + // get all exclusions without a type + const exclusionsWithoutType = await this.exclusionRepo + .createQueryBuilder('exclusion') + .where('type is null') + .getMany(); + + // correct the type + for (const el of exclusionsWithoutType) { + const metaData = await this.plexApi.getMetadata(el.plexId.toString()); + if (!metaData) { + // remove record if not in Plex + this.rulesService.removeExclusion(el.id); + } else { + el.type = metaData?.type + ? metaData.type === 'movie' + ? 1 + : metaData.type === 'show' + ? 2 + : metaData.type === 'season' + ? 3 + : metaData.type === 'episode' + ? 4 + : undefined + : undefined; + } + } + + // save edited data + this.exclusionRepo.save(exclusionsWithoutType); + } +} diff --git a/server/src/modules/rules/rule-executor.service.ts b/server/src/modules/rules/tasks/rule-executor.service.ts similarity index 92% rename from server/src/modules/rules/rule-executor.service.ts rename to server/src/modules/rules/tasks/rule-executor.service.ts index 52beafe1..9aee6418 100644 --- a/server/src/modules/rules/rule-executor.service.ts +++ b/server/src/modules/rules/tasks/rule-executor.service.ts @@ -1,19 +1,19 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import _ from 'lodash'; -import { PlexLibraryItem } from '../api/plex-api/interfaces/library.interfaces'; -import { PlexApiService } from '../api/plex-api/plex-api.service'; -import { CollectionsService } from '../collections/collections.service'; -import { AddCollectionMedia } from '../collections/interfaces/collection-media.interface'; -import { SettingsService } from '../settings/settings.service'; -import { TasksService } from '../tasks/tasks.service'; -import { RuleConstants } from './constants/rules.constants'; - -import { RulesDto } from './dtos/rules.dto'; -import { RuleGroup } from './entities/rule-group.entities'; -import { RulesService } from './rules.service'; -import { EPlexDataType } from '../api/plex-api/enums/plex-data-type-enum'; -import cacheManager, { Cache } from '../api/lib/cache'; -import { RuleComparatorService } from './helpers/rule.comparator.service'; +import { PlexLibraryItem } from '../../api/plex-api/interfaces/library.interfaces'; +import { PlexApiService } from '../../api/plex-api/plex-api.service'; +import { CollectionsService } from '../../collections/collections.service'; +import { AddCollectionMedia } from '../../collections/interfaces/collection-media.interface'; +import { SettingsService } from '../../settings/settings.service'; +import { TasksService } from '../../tasks/tasks.service'; +import { RuleConstants } from '../constants/rules.constants'; + +import { RulesDto } from '../dtos/rules.dto'; +import { RuleGroup } from '../entities/rule-group.entities'; +import { RulesService } from '../rules.service'; +import { EPlexDataType } from '../../api/plex-api/enums/plex-data-type-enum'; +import cacheManager from '../../api/lib/cache'; +import { RuleComparatorService } from '../helpers/rule.comparator.service'; interface PlexData { page: number; diff --git a/server/src/modules/rules/rule-maintenance.service.ts b/server/src/modules/rules/tasks/rule-maintenance.service.ts similarity index 90% rename from server/src/modules/rules/rule-maintenance.service.ts rename to server/src/modules/rules/tasks/rule-maintenance.service.ts index 65a3e5b8..73af49ee 100644 --- a/server/src/modules/rules/rule-maintenance.service.ts +++ b/server/src/modules/rules/tasks/rule-maintenance.service.ts @@ -1,11 +1,11 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; -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 { 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'; +import { Collection } from '../../collections/entities/collection.entities'; @Injectable() export class RuleMaintenanceService implements OnApplicationBootstrap { diff --git a/ui/src/components/Collection/CollectionDetail/Exclusions/index.tsx b/ui/src/components/Collection/CollectionDetail/Exclusions/index.tsx new file mode 100644 index 00000000..e0696455 --- /dev/null +++ b/ui/src/components/Collection/CollectionDetail/Exclusions/index.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from 'react' +import OverviewContent, { IPlexMetadata } from '../../../Overview/Content' +import _ from 'lodash' +import { ICollection } from '../..' +import GetApiHandler from '../../../../utils/ApiHandler' + +interface ICollectionExclusions { + collection: ICollection + libraryId: number +} + +export interface IExclusionMedia { + id: number + plexId: number + ruleGroupId: number + parent: number + type: number + plexData?: IPlexMetadata +} + +const CollectionExcludions = (props: ICollectionExclusions) => { + const [data, setData] = useState([]) + // paging + const pageData = useRef(0) + const fetchAmount = 25 + const [totalSize, setTotalSize] = useState(999) + const totalSizeRef = useRef(999) + const dataRef = useRef([]) + const loadingRef = useRef(true) + const loadingExtraRef = useRef(false) + const [page, setPage] = useState(0) + + useEffect(() => { + // Initial first fetch + setPage(1) + }, []) + + const handleScroll = () => { + if ( + window.innerHeight + document.documentElement.scrollTop >= + document.documentElement.scrollHeight * 0.9 + ) { + if ( + !loadingRef.current && + !loadingExtraRef.current && + !(fetchAmount * (pageData.current - 1) >= totalSizeRef.current) + ) { + setPage(pageData.current + 1) + } + } + } + + useEffect(() => { + if (page !== 0) { + // Ignore initial page render + pageData.current = pageData.current + 1 + fetchData() + } + }, [page]) + + useEffect(() => { + window.addEventListener('scroll', _.debounce(handleScroll.bind(this), 200)) + return () => { + window.removeEventListener( + 'scroll', + _.debounce(handleScroll.bind(this), 200), + ) + } + }, []) + + const fetchData = async () => { + if (!loadingRef.current) { + loadingExtraRef.current = true + } + // setLoading(true) + const resp: { totalSize: number; items: IExclusionMedia[] } = + await GetApiHandler( + `/collections/exclusions/${props.collection.id}/content/${pageData.current}?size=${fetchAmount}`, + ) + + setTotalSize(resp.totalSize) + // pageData.current = pageData.current + 1 + + setData([ + ...dataRef.current, + ...resp.items.map((el) => { + el.plexData!.maintainerrExclusionId = el.id + el.plexData!.maintainerrExclusionType = el.ruleGroupId + ? 'specific' + : 'global' + return el.plexData ? el.plexData : ({} as IPlexMetadata) + }), + ]) + loadingRef.current = false + loadingExtraRef.current = false + } + + useEffect(() => { + dataRef.current = data + + // If page is not filled yet, fetch more + if ( + !loadingRef.current && + !loadingExtraRef.current && + window.innerHeight + document.documentElement.scrollTop >= + document.documentElement.scrollHeight * 0.9 && + !(fetchAmount * (pageData.current - 1) >= totalSizeRef.current) + ) { + setPage(page + 1) + } + }, [data]) + + useEffect(() => { + totalSizeRef.current = totalSize + }, [totalSize]) + + return ( + {}} + loading={loadingRef.current} + data={data} + libraryId={props.libraryId} + collectionPage={true} + collectionId={props.collection.id} + extrasLoading={ + loadingExtraRef && + !loadingRef.current && + totalSize >= pageData.current * fetchAmount + } + onRemove={(id: string) => + setTimeout(() => { + setData(dataRef.current.filter((el) => +el.ratingKey !== +id)) + }, 500) + } + /> + ) +} +export default CollectionExcludions diff --git a/ui/src/components/Collection/CollectionDetail/RemoveFromCollectionBtn/index.tsx b/ui/src/components/Collection/CollectionDetail/RemoveFromCollectionBtn/index.tsx index 6c8547a9..1c77f9b3 100644 --- a/ui/src/components/Collection/CollectionDetail/RemoveFromCollectionBtn/index.tsx +++ b/ui/src/components/Collection/CollectionDetail/RemoveFromCollectionBtn/index.tsx @@ -2,23 +2,38 @@ import { TrashIcon } from '@heroicons/react/solid' import { useState } from 'react' import { DeleteApiHandler, PostApiHandler } from '../../../../utils/ApiHandler' import Button from '../../../Common/Button' +import Modal from '../../../Common/Modal' + interface IRemoveFromCollectionBtn { plexId: number collectionId: number + exclusionId?: number + popup?: boolean onRemove: () => void } const RemoveFromCollectionBtn = (props: IRemoveFromCollectionBtn) => { const [sure, setSure] = useState(false) + const [popup, setppopup] = useState(false) + + const handlePopup = () => { + if (props.popup) { + setppopup(!popup) + } + } const handle = () => { - DeleteApiHandler( - `/collections/media?mediaId=${props.plexId}&collectionId=${props.collectionId}`, - ) - PostApiHandler('/rules/exclusion', { - collectionId: props.collectionId, - mediaId: props.plexId, - action: 0, - }) + if (!props.exclusionId) { + DeleteApiHandler( + `/collections/media?mediaId=${props.plexId}&collectionId=${props.collectionId}`, + ) + PostApiHandler('/rules/exclusion', { + collectionId: props.collectionId, + mediaId: props.plexId, + action: 0, + }) + } else { + DeleteApiHandler(`/rules/exclusion/${props.exclusionId}`) + } props.onRemove() } @@ -39,11 +54,20 @@ const RemoveFromCollectionBtn = (props: IRemoveFromCollectionBtn) => { buttonType="primary" buttonSize="md" className="mt-2 mb-1 h-6 w-full text-zinc-200 shadow-md" - onClick={handle} + onClick={props.popup ? handlePopup : handle} >

{'Are you sure?'}

)} + + {popup ? ( + +

+ This item is excluded globally. Removing this exclusion will + apply the change to all collections +

+
+ ) : undefined} ) } diff --git a/ui/src/components/Collection/CollectionDetail/index.tsx b/ui/src/components/Collection/CollectionDetail/index.tsx index 13736a36..78a30dba 100644 --- a/ui/src/components/Collection/CollectionDetail/index.tsx +++ b/ui/src/components/Collection/CollectionDetail/index.tsx @@ -6,6 +6,8 @@ import GetApiHandler from '../../../utils/ApiHandler' import OverviewContent, { IPlexMetadata } from '../../Overview/Content' import _ from 'lodash' import TestMediaItem from './TestMediaItem' +import TabbedLinks, { TabbedRoute } from '../../Common/TabbedLinks' +import CollectionExcludions from './Exclusions' interface ICollectionDetail { libraryId: number @@ -20,6 +22,7 @@ const CollectionDetail: React.FC = ( ) => { const [data, setData] = useState([]) const [media, setMedia] = useState([]) + const [selectedTab, setSelectedTab] = useState('media') const [mediaTestModalOpen, setMediaTestModalOpen] = useState(false) // paging const pageData = useRef(0) @@ -132,6 +135,17 @@ const CollectionDetail: React.FC = ( } }, []) + const tabbedRoutes: TabbedRoute[] = [ + { + text: 'Media', + route: 'media', + }, + { + text: 'Exclusions', + route: 'exclusions', + }, + ] + return (
@@ -140,38 +154,58 @@ const CollectionDetail: React.FC = (
-
- {}} - loading={loadingRef.current} - data={data} - libraryId={props.libraryId} - collectionPage={true} - extrasLoading={ - loadingExtraRef && - !loadingRef.current && - totalSize >= pageData.current * fetchAmount - } - onRemove={(id: string) => - setTimeout(() => { - setData(dataRef.current.filter((el) => +el.ratingKey !== +id)) - setMedia(mediaRef.current.filter((el) => +el.plexId !== +id)) - }, 500) - } - collectionInfo={media.map((el) => { - props.collection.media = [] - el.collection = props.collection - return el - })} - /> +
+
+ setSelectedTab(t)} + routes={tabbedRoutes} + currentRoute={selectedTab} + allEnabled={true} + /> +
+
+
+ +
+ + {selectedTab === 'media' ? ( + {}} + loading={loadingRef.current} + data={data} + libraryId={props.libraryId} + collectionPage={true} + extrasLoading={ + loadingExtraRef && + !loadingRef.current && + totalSize >= pageData.current * fetchAmount + } + onRemove={(id: string) => + setTimeout(() => { + setData(dataRef.current.filter((el) => +el.ratingKey !== +id)) + setMedia(mediaRef.current.filter((el) => +el.plexId !== +id)) + }, 500) + } + collectionInfo={media.map((el) => { + props.collection.media = [] + el.collection = props.collection + return el + })} + /> + ) : selectedTab === 'exclusions' ? ( + + ) : undefined}
{mediaTestModalOpen && props.collection?.id ? ( diff --git a/ui/src/components/Collection/CollectionOverview/index.tsx b/ui/src/components/Collection/CollectionOverview/index.tsx index ce2de5cc..57696528 100644 --- a/ui/src/components/Collection/CollectionOverview/index.tsx +++ b/ui/src/components/Collection/CollectionOverview/index.tsx @@ -33,7 +33,10 @@ const CollectionOverview = (props: ICollectionOverview) => {
    {props.collections?.map((col) => ( -
  • +
  • void } @@ -36,11 +38,13 @@ const MediaCard: React.FC = ({ title, libraryId, type, - daysLeft = 999, collectionId = 0, + daysLeft = 999, + exclusionId = undefined, tmdbid = undefined, canExpand = false, collectionPage = false, + exclusionType = undefined, onRemove = (id: string) => {}, }) => { const isTouch = useIsTouch() @@ -141,10 +145,10 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-zinc-900' : mediaType === 'show' - ? 'bg-amber-900' - : mediaType === 'season' - ? 'bg-yellow-700' - : 'bg-rose-900' + ? 'bg-amber-900' + : mediaType === 'season' + ? 'bg-yellow-700' + : 'bg-rose-900' }`} >
    @@ -160,10 +164,10 @@ const MediaCard: React.FC = ({ mediaType === 'movie' ? 'bg-zinc-900' : mediaType === 'show' - ? 'bg-amber-900' - : mediaType === 'season' - ? 'bg-yellow-700' - : 'bg-rose-900' + ? 'bg-amber-900' + : mediaType === 'season' + ? 'bg-yellow-700' + : 'bg-rose-900' }`} >
    @@ -173,17 +177,18 @@ const MediaCard: React.FC = ({
    ) : undefined} - {collectionPage && daysLeft !== 999 ? ( + {/* on collection page and for the media items */} + {collectionPage && !exclusionType && daysLeft !== 999 ? (
    @@ -193,6 +198,27 @@ const MediaCard: React.FC = ({
    ) : undefined} + {/* on collection page and for the exclusions */} + {collectionPage && exclusionType === 'global' ? ( +
    +
    +
    + {exclusionType.toUpperCase()} +
    +
    +
    + ) : undefined} + = ({ ) : ( onRemove(id.toString())} collectionId={collectionId} + exclusionId={exclusionId} /> )}
    diff --git a/ui/src/components/Common/TabbedLinks/index.tsx b/ui/src/components/Common/TabbedLinks/index.tsx new file mode 100644 index 00000000..b087b599 --- /dev/null +++ b/ui/src/components/Common/TabbedLinks/index.tsx @@ -0,0 +1,72 @@ +import React, { ReactNode } from 'react' + +export interface TabbedRoute { + text: string + content?: React.ReactNode + route: string +} + +export interface ItabbedLinks { + routes: TabbedRoute[] + allEnabled?: boolean + currentRoute?: string + onChange: (target: string) => void +} + +export interface ITabbedLink { + currentRoute: string + route: string + hidden?: boolean + disabled?: boolean + children?: ReactNode + onClick: (path: string) => void +} + +const TabbedLink = (props: ITabbedLink) => { + let linkClasses = + (props.disabled ? 'pointer-events-none touch-none ' : 'cursor-pointer ') + + 'px-1 py-4 ml-8 text-md font-semibold leading-5 transition duration-300 leading-5 whitespace-nowrap first:ml-0' + let activeLinkColor = ' border-b text-amber-500 border-amber-600' + let inactiveLinkColor = + 'border-transparent text-zinc-500 hover:border-b focus:border-b hover:text-zinc-300 hover:border-zinc-400 focus:text-zinc-300 focus:border-zinc-400' + + return ( + props.onClick(props.route)} + className={`${linkClasses} ${ + props.currentRoute.match(props.route) + ? activeLinkColor + : inactiveLinkColor + }`} + aria-current="page" + > + {props.children} + + ) +} + +const TabbedLinks = (props: ItabbedLinks) => { + return ( + <> +
    + +
    + + ) +} + +export default TabbedLinks diff --git a/ui/src/components/Overview/Content/index.tsx b/ui/src/components/Overview/Content/index.tsx index 2c98324a..acced07c 100644 --- a/ui/src/components/Overview/Content/index.tsx +++ b/ui/src/components/Overview/Content/index.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { ICollection, ICollectionMedia } from '../../Collection' +import { ICollectionMedia } from '../../Collection' import LoadingSpinner, { SmallLoadingSpinner, } from '../../Common/LoadingSpinner' @@ -16,6 +16,7 @@ interface IOverviewContent { libraryId: number collectionPage?: boolean collectionInfo?: ICollectionMedia[] + collectionId?: number } export interface IPlexMetadata { @@ -67,6 +68,8 @@ export interface IPlexMetadata { parentYear?: number grandParentYear?: number index?: number + maintainerrExclusionType?: 'specific' | 'global' // this is added by Maintainerr, not a Plex type + maintainerrExclusionId?: number // this is added by Maintainerr, not a Plex type } const OverviewContent = (props: IOverviewContent) => { @@ -132,76 +135,83 @@ const OverviewContent = (props: IOverviewContent) => {
      {props.data.map((el) => (
    • - - e.id.includes('tmdb'), - )?.id.split('tmdb://')[1] - : el.Guid + } + userScore={el.audienceRating ? el.audienceRating : 0} + exclusionId={ + el.maintainerrExclusionId + ? el.maintainerrExclusionId + : undefined + } + tmdbid={ + el.parentData + ? el.parentData.Guid.find((e) => + e.id.includes('tmdb'), + )?.id.split('tmdb://')[1] + : el.Guid ? el.Guid.find((e) => e.id.includes('tmdb'))?.id.split( 'tmdb://', )[1] : undefined - } - collectionPage={ - props.collectionPage ? props.collectionPage : false - } - onRemove={props.onRemove} - {...(props.collectionInfo - ? { - daysLeft: getDaysLeft(+el.ratingKey), - collectionId: props.collectionInfo.find( - (colEl) => colEl.plexId === +el.ratingKey, - )?.collectionId, - } - : {})} - /> + } + collectionPage={ + props.collectionPage ? props.collectionPage : false + } + exclusionType={el.maintainerrExclusionType} + onRemove={props.onRemove} + collectionId={props.collectionId} + {...(props.collectionInfo + ? { + daysLeft: getDaysLeft(+el.ratingKey), + collectionId: props.collectionInfo.find( + (colEl) => colEl.plexId === +el.ratingKey, + )?.collectionId, + } + : undefined)} + />
    • ))} {props.extrasLoading ? : undefined} diff --git a/ui/src/components/Settings/Tabs/index.tsx b/ui/src/components/Settings/Tabs/index.tsx index efdae3e9..de79bf80 100644 --- a/ui/src/components/Settings/Tabs/index.tsx +++ b/ui/src/components/Settings/Tabs/index.tsx @@ -33,8 +33,8 @@ const SettingsLink: React.FC = (props: ISettingsLink) => { let linkClasses = (props.disabled ? 'pointer-events-none touch-none ' : '') + - 'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 border-transparent whitespace-nowrap first:ml-0' - let activeLinkColor = 'text-amber-500 border-amber-600' + 'px-1 py-4 ml-8 text-sm font-medium leading-5 transition duration-300 border-b-2 whitespace-nowrap first:ml-0' + let activeLinkColor = 'text-amber-500 border-amber-600 border-b' let inactiveLinkColor = 'text-zinc-500 border-transparent hover:text-zinc-300 hover:border-zinc-400 focus:text-zinc-300 focus:border-zinc-400' diff --git a/yarn.lock b/yarn.lock index 2ab24405..64a31970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1087,7 +1087,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.23.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg== @@ -4602,7 +4602,7 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -cosmiconfig@^8.1.3, cosmiconfig@^8.2.0: +cosmiconfig@^8.0.0, cosmiconfig@^8.1.3, cosmiconfig@^8.2.0: version "8.3.6" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==