Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"axios-retry": "^4.5.0",
"better-sqlite3": "^12.8.0",
"chalk": "^4.1.2",
"cron": "^4.4.0",
"cron-validator": "^1.4.0",
"email-templates": "13.0.1",
"http-terminator": "^3.2.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { NotificationService } from '../modules/notifications/notifications.serv
import { RulesModule } from '../modules/rules/rules.module';
import { SettingsModule } from '../modules/settings/settings.module';
import { SettingsService } from '../modules/settings/settings.service';
import { StatsModule } from '../modules/stats/stats.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import ormConfig from './config/typeOrmConfig';
Expand All @@ -48,6 +49,7 @@ import ormConfig from './config/typeOrmConfig';
TautulliApiModule,
RulesModule,
CollectionsModule,
StatsModule,
NotificationsModule,
EventsModule,
ServeStaticModule.forRootAsync({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,20 @@ export class PlexAdapterService implements IMediaServerService {
libraryId: string,
options?: RecentlyAddedOptions,
): Promise<MediaItem[]> {
// PlexApiService.getRecentlyAdded uses addedAt timestamp, not limit/type
// We'll use the default (items added in last hour)
const results = await this.plexApi.getRecentlyAdded(libraryId);
const response = await this.plexApi.getLibraryContents(
libraryId,
{
offset: 0,
size: options?.limit ?? 50,
sort: 'addedAt:desc',
},
undefined,
false,
);
const results = response?.items;

if (!results) return [];

// Apply limit if provided
const limited = options?.limit ? results.slice(0, options.limit) : results;
return limited.map(PlexMapper.toMediaItem);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export class CollectionsController {
return this.collectionService.getCollectionMediaCount(collectionId);
}

@Get('/storage-summary')
getStorageSummary() {
return this.collectionService.getCollectionStorageSummary();
}

@Get('/media/:id/content/:page')
getLibraryContent(
@Param('id', ParseIntPipe) id: number,
Expand Down
160 changes: 159 additions & 1 deletion apps/server/src/modules/collections/collections.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BasicResponseDto,
CollectionLogMeta,
CollectionLogMediaSnapshot,
ECollectionLogType,
isMediaType,
MaintainerrEvent,
Expand Down Expand Up @@ -43,6 +44,23 @@ import {
ServarrAction,
} from './interfaces/collection.interface';

export interface CollectionStorageSummary {
collectionCount: number;
sizedCollectionCount: number;
totalSizeBytes: number;
reclaimableCollectionCount: number;
reclaimableSizeBytes: number;
byLibrary: CollectionStorageLibrarySummary[];
}

export interface CollectionStorageLibrarySummary {
libraryId: string;
collectionCount: number;
collectedCount: number;
totalSizeBytes: number;
reclaimableSizeBytes: number;
}

interface addCollectionDbResponse {
id: number;
mediaServerId?: string;
Expand Down Expand Up @@ -129,6 +147,90 @@ export class CollectionsService {
return await this.CollectionMediaRepo.count();
}

public async getCollectionStorageSummary(): Promise<CollectionStorageSummary> {
const collections = await this.collectionRepo.find({
select: {
id: true,
libraryId: true,
isActive: true,
deleteAfterDays: true,
totalSizeBytes: true,
},
});
const mediaCountRows = await this.CollectionMediaRepo.createQueryBuilder(
'media',
)
.select('media.collectionId', 'collectionId')
.addSelect('COUNT(media.id)', 'mediaCount')
.groupBy('media.collectionId')
.getRawMany<{ collectionId: number; mediaCount: string }>();
const mediaCountByCollection = new Map(
mediaCountRows.map((row) => [
Number(row.collectionId),
Number(row.mediaCount),
]),
);
const librarySummaryById = new Map<
string,
CollectionStorageLibrarySummary
>();

const summary = collections.reduce<CollectionStorageSummary>(
(summary, collection) => {
const sizeBytes = Number(collection.totalSizeBytes ?? 0);
const hasSize = Number.isFinite(sizeBytes) && sizeBytes > 0;
const isReclaimable =
collection.isActive && (collection.deleteAfterDays ?? 0) > 0;
const librarySummary =
librarySummaryById.get(collection.libraryId) ??
({
libraryId: collection.libraryId,
collectionCount: 0,
collectedCount: 0,
totalSizeBytes: 0,
reclaimableSizeBytes: 0,
} satisfies CollectionStorageLibrarySummary);

summary.collectionCount += 1;
librarySummary.collectionCount += 1;
librarySummary.collectedCount +=
mediaCountByCollection.get(collection.id) ?? 0;

if (hasSize) {
summary.sizedCollectionCount += 1;
summary.totalSizeBytes += sizeBytes;
librarySummary.totalSizeBytes += sizeBytes;
}

if (isReclaimable) {
summary.reclaimableCollectionCount += 1;

if (hasSize) {
summary.reclaimableSizeBytes += sizeBytes;
librarySummary.reclaimableSizeBytes += sizeBytes;
}
}

librarySummaryById.set(collection.libraryId, librarySummary);
return summary;
},
{
collectionCount: 0,
sizedCollectionCount: 0,
totalSizeBytes: 0,
reclaimableCollectionCount: 0,
reclaimableSizeBytes: 0,
byLibrary: [],
},
);

summary.byLibrary = [...librarySummaryById.values()].sort((a, b) =>
a.libraryId.localeCompare(b.libraryId),
);

return summary;
}

public async getCollectionMediaWithServerDataAndPaging(
id: number,
{ offset = 0, size = 25 }: { offset?: number; size?: number } = {},
Expand Down Expand Up @@ -1204,6 +1306,12 @@ export class CollectionsService {
// if there's no data.. skip logging

if (mediaData) {
const logMetaWithMedia = await this.addMediaSnapshotToLogMeta(
logMeta,
mediaData,
mediaServer,
type,
);
const subject = isMediaType(mediaData.type, 'episode')
? `${mediaData.grandparentTitle} - season ${mediaData.parentIndex} - episode ${mediaData.index}`
: isMediaType(mediaData.type, 'season')
Expand All @@ -1213,11 +1321,61 @@ export class CollectionsService {
{ id: collectionId } as Collection,
`${type === 'add' ? 'Added' : type === 'handle' ? 'Successfully handled' : type === 'exclude' ? 'Added a specific exclusion for' : type === 'include' ? 'Removed specific exclusion of' : 'Removed'} "${subject}"`,
ECollectionLogType.MEDIA,
logMeta,
logMetaWithMedia,
);
}
}

private async addMediaSnapshotToLogMeta(
logMeta: CollectionLogMeta | undefined,
mediaData: MediaItem,
mediaServer: IMediaServerService,
actionType: 'add' | 'remove' | 'handle' | 'exclude' | 'include',
): Promise<CollectionLogMeta> {
const media = await this.getLogMediaSnapshot(mediaData, mediaServer);

if (logMeta) {
return { ...logMeta, media } as CollectionLogMeta;
}

return {
type:
actionType === 'remove'
? 'media_removed_manually'
: 'media_added_manually',
media,
};
}

private async getLogMediaSnapshot(
mediaData: MediaItem,
mediaServer: IMediaServerService,
): Promise<CollectionLogMediaSnapshot> {
const parentId =
mediaData.type === 'episode'
? mediaData.grandparentId
: mediaData.type === 'season'
? mediaData.parentId
: undefined;
const parentItem = parentId
? await mediaServer.getMetadata(parentId)
: undefined;
const posterSource = mediaData.type === 'movie' ? mediaData : parentItem;

return {
mediaServerId: mediaData.id,
mediaType: mediaData.type,
title: mediaData.title,
parentTitle: mediaData.parentTitle,
grandparentTitle: mediaData.grandparentTitle,
seasonNumber:
mediaData.type === 'season' ? mediaData.index : mediaData.parentIndex,
episodeNumber: mediaData.type === 'episode' ? mediaData.index : undefined,
tmdbId: posterSource?.providerIds?.tmdb?.[0],
posterType: mediaData.type === 'movie' ? 'movie' : 'show',
};
}

private async removeChildFromCollection(
collectionIds: { mediaServerId: string; dbId: number },
childId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('SettingsController', () => {
});

it('sets database download headers and returns streamable file', async () => {
const fileStream = createReadStream('/etc/hosts');
const fileStream = createReadStream(__filename);
databaseDownloadService.getDatabaseDownload.mockResolvedValue({
fileStream,
fileName: 'maintainerr.db',
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/modules/stats/stats.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppStatsResponse, StatsService } from './stats.service';

@Controller('api/stats')
export class StatsController {
constructor(private readonly statsService: StatsService) {}

@Get()
getStats(): Promise<AppStatsResponse> {
return this.statsService.getStats();
}
}
22 changes: 22 additions & 0 deletions apps/server/src/modules/stats/stats.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MediaServerModule } from '../api/media-server/media-server.module';
import { ServarrApiModule } from '../api/servarr-api/servarr-api.module';
import { CollectionsModule } from '../collections/collections.module';
import { CollectionLog } from '../collections/entities/collection_log.entities';
import { RuleGroup } from '../rules/entities/rule-group.entities';
import { SonarrSettings } from '../settings/entities/sonarr_settings.entities';
import { StatsController } from './stats.controller';
import { StatsService } from './stats.service';

@Module({
imports: [
CollectionsModule,
MediaServerModule,
ServarrApiModule,
TypeOrmModule.forFeature([CollectionLog, RuleGroup, SonarrSettings]),
],
controllers: [StatsController],
providers: [StatsService],
})
export class StatsModule {}
Loading
Loading