Skip to content

Commit fca5f84

Browse files
authored
fix(collections): credit reclaimed bytes when sizeBytes is not yet cached (#2855)
1 parent 932b5ca commit fca5f84

3 files changed

Lines changed: 173 additions & 29 deletions

File tree

apps/server/src/modules/collections/collection-handler.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,120 @@ describe('CollectionHandler', () => {
447447
expect(collectionsService.saveCollection).not.toHaveBeenCalled();
448448
});
449449

450+
it('credits cached sizeBytes to handledMediaSizeBytes for delete-style actions', async () => {
451+
const collection = createCollection({
452+
arrAction: ServarrAction.DELETE,
453+
type: 'episode',
454+
});
455+
const collectionMedia = createCollectionMedia(collection);
456+
collectionMedia.sizeBytes = 1_500_000_000 as any;
457+
458+
mediaServer.getLibraries.mockResolvedValue(
459+
createMediaLibraries({
460+
id: collection.libraryId.toString(),
461+
type: 'show',
462+
}),
463+
);
464+
465+
await collectionHandler.handleMedia(collection, collectionMedia);
466+
467+
expect(collectionsService.resolveItemSize).not.toHaveBeenCalled();
468+
expect(collectionsService.saveCollection).toHaveBeenCalledWith(
469+
expect.objectContaining({
470+
handledMediaAmount: 1,
471+
handledMediaSizeBytes: 1_500_000_000,
472+
}),
473+
);
474+
});
475+
476+
it('falls back to media-server lookup when sizeBytes is null on a delete-style action', async () => {
477+
const collection = createCollection({
478+
arrAction: ServarrAction.DELETE,
479+
type: 'episode',
480+
});
481+
const collectionMedia = createCollectionMedia(collection);
482+
collectionMedia.sizeBytes = null as any;
483+
484+
mediaServer.getLibraries.mockResolvedValue(
485+
createMediaLibraries({
486+
id: collection.libraryId.toString(),
487+
type: 'show',
488+
}),
489+
);
490+
collectionsService.resolveItemSize.mockResolvedValue(2_000_000_000);
491+
492+
await collectionHandler.handleMedia(collection, collectionMedia);
493+
494+
expect(collectionsService.resolveItemSize).toHaveBeenCalledWith(
495+
mediaServer,
496+
collectionMedia.mediaServerId,
497+
);
498+
expect(
499+
collectionsService.resolveItemSize.mock.invocationCallOrder[0],
500+
).toBeLessThan(mediaServer.deleteFromDisk.mock.invocationCallOrder[0]);
501+
expect(collectionsService.saveCollection).toHaveBeenCalledWith(
502+
expect.objectContaining({
503+
handledMediaAmount: 1,
504+
handledMediaSizeBytes: 2_000_000_000,
505+
}),
506+
);
507+
});
508+
509+
it('does not look up size for unmonitor actions', async () => {
510+
const collection = createCollection({
511+
arrAction: ServarrAction.UNMONITOR,
512+
sonarrSettingsId: 1,
513+
type: 'show',
514+
});
515+
const collectionMedia = createCollectionMedia(collection);
516+
collectionMedia.sizeBytes = null as any;
517+
518+
mediaServer.getLibraries.mockResolvedValue(
519+
createMediaLibraries({
520+
id: collection.libraryId.toString(),
521+
type: 'show',
522+
}),
523+
);
524+
sonarrActionHandler.handleAction.mockResolvedValue(true);
525+
526+
await collectionHandler.handleMedia(collection, collectionMedia);
527+
528+
expect(collectionsService.resolveItemSize).not.toHaveBeenCalled();
529+
expect(collectionsService.saveCollection).toHaveBeenCalledWith(
530+
expect.objectContaining({
531+
handledMediaAmount: 1,
532+
handledMediaSizeBytes: 0,
533+
}),
534+
);
535+
});
536+
537+
it('skips byte credit when the lookup also fails to resolve a size', async () => {
538+
const collection = createCollection({
539+
arrAction: ServarrAction.DELETE,
540+
type: 'episode',
541+
});
542+
const collectionMedia = createCollectionMedia(collection);
543+
collectionMedia.sizeBytes = null as any;
544+
545+
mediaServer.getLibraries.mockResolvedValue(
546+
createMediaLibraries({
547+
id: collection.libraryId.toString(),
548+
type: 'show',
549+
}),
550+
);
551+
collectionsService.resolveItemSize.mockResolvedValue(null);
552+
553+
await collectionHandler.handleMedia(collection, collectionMedia);
554+
555+
expect(collectionsService.resolveItemSize).toHaveBeenCalled();
556+
expect(collectionsService.saveCollection).toHaveBeenCalledWith(
557+
expect.objectContaining({
558+
handledMediaAmount: 1,
559+
handledMediaSizeBytes: 0,
560+
}),
561+
);
562+
});
563+
450564
it('should not call SeerrApiService if Seerr is not configured', async () => {
451565
const collection = createCollection({
452566
arrAction: ServarrAction.DELETE,

apps/server/src/modules/collections/collection-handler.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ export class CollectionHandler {
5050
(e) => e.id === collection.libraryId.toString(),
5151
);
5252

53+
// Resolve the on-disk size before running the action. The size cache is
54+
// populated lazily by the collection size sync; if the handler runs
55+
// against a freshly-added item before the next sync, `media.sizeBytes`
56+
// is null and the post-action increment below would silently drop the
57+
// bytes. After a delete-style action the file is gone and the media
58+
// server's metadata loses the size, so this lookup has to happen first.
59+
const freesDisk =
60+
collection.arrAction !== ServarrAction.UNMONITOR &&
61+
collection.arrAction !== ServarrAction.UNMONITOR_SHOW_IF_EMPTY &&
62+
collection.arrAction !== ServarrAction.CHANGE_QUALITY_PROFILE;
63+
let resolvedSizeBytes: number | null =
64+
media.sizeBytes != null && Number(media.sizeBytes) > 0
65+
? Number(media.sizeBytes)
66+
: null;
67+
if (freesDisk && resolvedSizeBytes === null) {
68+
resolvedSizeBytes = await this.collectionService.resolveItemSize(
69+
mediaServer,
70+
media.mediaServerId,
71+
);
72+
}
73+
5374
let actionHandled = false;
5475

5576
if (library?.type === 'movie' && collection.radarrSettingsId) {
@@ -161,15 +182,12 @@ export class CollectionHandler {
161182

162183
collection.handledMediaAmount++;
163184

164-
// Only credit bytes when the action actually frees disk space.
165-
// Unmonitor / quality-change actions leave files on disk.
166-
const freesDisk =
167-
collection.arrAction !== ServarrAction.UNMONITOR &&
168-
collection.arrAction !== ServarrAction.UNMONITOR_SHOW_IF_EMPTY &&
169-
collection.arrAction !== ServarrAction.CHANGE_QUALITY_PROFILE;
170-
if (freesDisk && media.sizeBytes != null && media.sizeBytes > 0) {
185+
// Credit bytes for delete-style actions only; unmonitor / quality-change
186+
// leave files on disk. `resolvedSizeBytes` was captured before the action
187+
// ran so it survives the file being gone afterwards.
188+
if (freesDisk && resolvedSizeBytes != null && resolvedSizeBytes > 0) {
171189
collection.handledMediaSizeBytes =
172-
Number(collection.handledMediaSizeBytes ?? 0) + Number(media.sizeBytes);
190+
Number(collection.handledMediaSizeBytes ?? 0) + resolvedSizeBytes;
173191
}
174192

175193
await this.collectionService.CollectionLogRecordForChild(

apps/server/src/modules/collections/collections.service.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3010,27 +3010,10 @@ export class CollectionsService {
30103010
let hasAnySize = false;
30113011

30123012
for (const media of collectionMedia) {
3013-
let itemSize: number | null = null;
3014-
try {
3015-
const metadata = await mediaServer.getMetadata(media.mediaServerId);
3016-
if (metadata) {
3017-
const directSize = this.sumMediaSourceSizes(metadata);
3018-
if (directSize > 0) {
3019-
itemSize = directSize;
3020-
} else if (metadata.type === 'show' || metadata.type === 'season') {
3021-
const childSize = await this.getChildrenTotalSize(
3022-
mediaServer,
3023-
metadata,
3024-
);
3025-
if (childSize > 0) itemSize = childSize;
3026-
}
3027-
}
3028-
} catch (error) {
3029-
this.logger.debug(
3030-
`Failed to get size for media ${media.mediaServerId}`,
3031-
);
3032-
this.logger.debug(error);
3033-
}
3013+
const itemSize = await this.resolveItemSize(
3014+
mediaServer,
3015+
media.mediaServerId,
3016+
);
30343017

30353018
if (itemSize != null && itemSize > 0) {
30363019
totalBytes += itemSize;
@@ -3060,6 +3043,35 @@ export class CollectionsService {
30603043
}
30613044
}
30623045

3046+
/**
3047+
* Resolve the on-disk size of a single media item via the media server,
3048+
* falling back to summing children for shows/seasons. Returns null when
3049+
* the lookup fails or the server reports no usable size.
3050+
*/
3051+
async resolveItemSize(
3052+
mediaServer: IMediaServerService,
3053+
mediaServerId: string,
3054+
): Promise<number | null> {
3055+
try {
3056+
const metadata = await mediaServer.getMetadata(mediaServerId);
3057+
if (!metadata) return null;
3058+
const directSize = this.sumMediaSourceSizes(metadata);
3059+
if (directSize > 0) return directSize;
3060+
if (metadata.type === 'show' || metadata.type === 'season') {
3061+
const childSize = await this.getChildrenTotalSize(
3062+
mediaServer,
3063+
metadata,
3064+
);
3065+
if (childSize > 0) return childSize;
3066+
}
3067+
return null;
3068+
} catch (error) {
3069+
this.logger.debug(`Failed to get size for media ${mediaServerId}`);
3070+
this.logger.debug(error);
3071+
return null;
3072+
}
3073+
}
3074+
30633075
/**
30643076
* Sum sizeBytes across all mediaSources on a MediaItem.
30653077
*/

0 commit comments

Comments
 (0)