diff --git a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts
index 5f7b757cb..005dc3d11 100644
--- a/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts
+++ b/client/src/features/book/components/__tests__/BookFilterBuilder.spec.ts
@@ -83,6 +83,34 @@ describe('BookFilterBuilder', () => {
expect(optionText).toContain('Date Finished')
})
+ it('serializes primary file size input from megabytes to bytes', async () => {
+ const wrapper = mount(BookFilterBuilder, {
+ props: {
+ modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Dune' }] },
+ },
+ })
+
+ await wrapper.findAll('select')[0]!.setValue('fileSize')
+ await wrapper.findAll('select')[1]!.setValue('gte')
+ await wrapper.get('input[aria-label="Primary file size in megabytes"]').setValue('10.5')
+
+ expect(lastUpdate(wrapper)).toEqual({
+ type: 'group',
+ join: 'AND',
+ rules: [{ type: 'rule', field: 'fileSize', operator: 'gte', value: 11_010_048, valueTo: undefined }],
+ })
+ })
+
+ it('hydrates saved primary file size bytes as megabytes', () => {
+ const wrapper = mount(BookFilterBuilder, {
+ props: {
+ modelValue: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'fileSize', operator: 'lte', value: 26_214_400 }] },
+ },
+ })
+
+ expect((wrapper.get('input[aria-label="Primary file size in megabytes"]').element as HTMLInputElement).value).toBe('25')
+ })
+
it('offers date and empty/not-empty operators for Date Started', async () => {
const wrapper = mount(BookFilterBuilder, {
props: {
diff --git a/client/src/features/book/lib/__tests__/filter-labels.spec.ts b/client/src/features/book/lib/__tests__/filter-labels.spec.ts
index cd1663c48..fb97d4318 100644
--- a/client/src/features/book/lib/__tests__/filter-labels.spec.ts
+++ b/client/src/features/book/lib/__tests__/filter-labels.spec.ts
@@ -170,6 +170,11 @@ describe('ruleToParts', () => {
expect(ruleToParts(rule)).toEqual({ field: 'Page Count', operator: 'between', value: '100 - 200' })
})
+ it('formats primary file size values as readable byte sizes', () => {
+ const rule: Rule = { type: 'rule', field: 'fileSize', operator: 'between', value: 10_485_760, valueTo: 26_214_400 }
+ expect(ruleToParts(rule)).toEqual({ field: 'Primary File Size', operator: 'between', value: '10 MB - 25 MB' })
+ })
+
it('renders an empty value when a valued operator has no value', () => {
const rule: Rule = { type: 'rule', field: 'title', operator: 'eq' }
expect(ruleToParts(rule)).toEqual({ field: 'Title', operator: 'is', value: '' })
diff --git a/client/src/features/book/lib/filter-labels.ts b/client/src/features/book/lib/filter-labels.ts
index 8f29dab65..6b0486d64 100644
--- a/client/src/features/book/lib/filter-labels.ts
+++ b/client/src/features/book/lib/filter-labels.ts
@@ -3,6 +3,7 @@ import { parseCustomRuleFieldId, parseCustomSortFieldId } from '@bookorbit/types
import { i18n } from '@/i18n'
import { activeCustomFieldLabel } from '@/features/book/composables/useActiveCustomFields'
import { PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
+import { formatBytes } from '@/lib/formatting'
// Resolved through the global composer rather than useI18n() because these helpers also run
// outside a component setup (useViewSort, plain computeds). Composer.t tracks the active locale,
@@ -54,6 +55,11 @@ export function ruleToParts(rule: Rule): { field: string; operator: string; valu
const operator = operatorLabel(rule.operator)
if (NO_VALUE_OPS.includes(rule.operator)) return { field, operator, value: null }
if (rule.operator === 'withinLast') return { field, operator, value: `${rule.value} ${t('book.filter.unit.days')}` }
+ if (rule.field === 'fileSize') {
+ const value = typeof rule.value === 'number' ? formatBytes(rule.value) : ''
+ const valueTo = typeof rule.valueTo === 'number' ? formatBytes(rule.valueTo) : null
+ return { field, operator, value: valueTo === null ? value : `${value} - ${valueTo}` }
+ }
const val = Array.isArray(rule.value) ? (rule.value as string[]).join(', ') : String(rule.value ?? '')
return { field, operator, value: rule.valueTo !== undefined ? `${val} - ${rule.valueTo}` : val }
}
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 1f4922108..4371653eb 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -4054,6 +4054,7 @@
"collection": "Collection",
"library": "Library",
"format": "Format",
+ "fileSize": "Primary File Size",
"addedAt": "Added Date",
"startedAt": "Date Started",
"finishedAt": "Date Finished",
@@ -4112,7 +4113,8 @@
"unit": {
"days": "days",
"weeks": "weeks",
- "months": "months"
+ "months": "months",
+ "megabytes": "MB"
},
"scorePreset": {
"outstanding": "Outstanding",
@@ -4121,6 +4123,9 @@
"poor": "Poor"
},
"scoreRangePlaceholder": "0-100",
+ "fileSizePlaceholder": "e.g. 10",
+ "fileSizeValueAria": "Primary file size in megabytes",
+ "fileSizeMaximumAria": "Maximum primary file size in megabytes",
"valuePlaceholder": "value",
"maxPlaceholder": "max",
"to": "to",
diff --git a/packages/types/src/query.ts b/packages/types/src/query.ts
index eed6ff70a..e8d787f59 100644
--- a/packages/types/src/query.ts
+++ b/packages/types/src/query.ts
@@ -17,6 +17,7 @@ import type { CommunityRatingProviderKey } from "./metadata-fetch";
* - `collection` - resolved via `collection_books` join to `collections.name`
* - `library` - resolved via `books.library_id` join to `libraries.name`
* - `format` - resolved via `book_files.format` (primary file)
+ * - `fileSize` - resolved via `book_files.size_bytes` (primary file; rule values are bytes)
* - `isbn` - matches both `isbn10` and `isbn13` in `book_metadata`
* - `publishedDate` - uses full dates when available and falls back to published year
* - `lockStatus` - derived from `book_metadata.locked_fields` (non-empty array = locked)
@@ -39,6 +40,7 @@ export type StaticRuleField =
| "collection"
| "library"
| "format"
+ | "fileSize"
| "addedAt"
| "startedAt"
| "finishedAt"
@@ -109,6 +111,7 @@ export const FIELD_OPERATORS: Record = {
collection: ["includesAny", "excludesAll", "isEmpty", "isNotEmpty"],
library: ["includesAny", "excludesAll"],
format: ["includesAny", "excludesAll"],
+ fileSize: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
publishedDate: ["before", "after", "between", "withinLast", "isEmpty", "isNotEmpty"],
publishedYear: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
seriesIndex: ["eq", "notEq", "gt", "gte", "lt", "lte", "between", "isEmpty", "isNotEmpty"],
diff --git a/server/src/modules/book/book-query-builder.service.test.ts b/server/src/modules/book/book-query-builder.service.test.ts
index 7b020a297..44fc71099 100644
--- a/server/src/modules/book/book-query-builder.service.test.ts
+++ b/server/src/modules/book/book-query-builder.service.test.ts
@@ -136,7 +136,7 @@ const USER_CTX = { accessibleLibraryIds: [1] as number[], userId: 10 };
* new operator is added to RuleOperator but not handled here.
*/
function buildValueFor(operator: RuleOperator, field: RuleField): { value?: unknown; valueTo?: unknown } {
- const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'rating', 'communityRating', 'metadataScore'];
+ const numericFields: RuleField[] = ['publishedYear', 'seriesIndex', 'pageCount', 'fileSize', 'rating', 'communityRating', 'metadataScore'];
const dateFields: RuleField[] = ['publishedDate', 'addedAt', 'startedAt', 'finishedAt'];
const isNumericField = numericFields.includes(field);
@@ -879,6 +879,16 @@ describe('numericRuleToSql (via pageCount and publishedYear)', () => {
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'publishedYear', operator: 'notEq', value: 2001 }) as never, BASE_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'ne' });
});
+
+ it('filters file size through the selected primary file', () => {
+ const { builder } = makeBuilder();
+ const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'fileSize', operator: 'gte', value: 10_485_760 }) as never, BASE_CTX) as any;
+ const clause = getRuleSql(where) as any;
+
+ expect(clause).toMatchObject({ type: 'gte', right: 10_485_760 });
+ expect(clause.left).toMatchObject({ type: 'sql' });
+ expect(collectColumnNames(clause.left)).toEqual(expect.arrayContaining(['size_bytes', 'id', 'primary_file_id']));
+ });
});
describe('authorRuleToSql', () => {
diff --git a/server/src/modules/book/book-query-builder.service.ts b/server/src/modules/book/book-query-builder.service.ts
index a02b18c35..53e8b310b 100644
--- a/server/src/modules/book/book-query-builder.service.ts
+++ b/server/src/modules/book/book-query-builder.service.ts
@@ -186,6 +186,13 @@ export class BookQueryBuilder {
return this.libraryRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
+ case 'fileSize':
+ return this.numericRuleToSql(
+ sql`(SELECT ${bookFiles.sizeBytes} FROM ${bookFiles} WHERE ${bookFiles.id} = ${books.primaryFileId})`,
+ operator,
+ value as number,
+ valueTo as number | undefined,
+ );
case 'addedAt':
return this.dateRuleToSql(operator, value as string | number, valueTo as string | undefined);
case 'startedAt':
@@ -254,34 +261,35 @@ export class BookQueryBuilder {
}
}
- private numericRuleToSql(col: AnyColumn, operator: string, value?: number, valueTo?: number): SQL {
+ private numericRuleToSql(col: AnyColumn | SQL, operator: string, value?: number, valueTo?: number): SQL {
+ const expression = col as SQL;
switch (operator) {
case 'eq':
this.assertNumber(value, operator, 'value');
- return eq(col, value!);
+ return eq(expression, value!);
case 'notEq':
this.assertNumber(value, operator, 'value');
- return ne(col, value!);
+ return ne(expression, value!);
case 'gt':
this.assertNumber(value, operator, 'value');
- return gt(col, value!);
+ return gt(expression, value!);
case 'gte':
this.assertNumber(value, operator, 'value');
- return gte(col, value!);
+ return gte(expression, value!);
case 'lt':
this.assertNumber(value, operator, 'value');
- return lt(col, value!);
+ return lt(expression, value!);
case 'lte':
this.assertNumber(value, operator, 'value');
- return lte(col, value!);
+ return lte(expression, value!);
case 'between':
this.assertNumber(value, operator, 'value');
this.assertNumber(valueTo, operator, 'valueTo');
- return and(gte(col, value!), lte(col, valueTo!))!;
+ return and(gte(expression, value!), lte(expression, valueTo!))!;
case 'isEmpty':
- return isNull(col);
+ return isNull(expression);
case 'isNotEmpty':
- return isNotNull(col);
+ return isNotNull(expression);
default:
throw new BadRequestException(`Invalid operator '${operator}' for numeric field`);
}
diff --git a/server/test/book-api-contract.e2e-spec.ts b/server/test/book-api-contract.e2e-spec.ts
index 3a94f95bd..50af77367 100644
--- a/server/test/book-api-contract.e2e-spec.ts
+++ b/server/test/book-api-contract.e2e-spec.ts
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
-import { rm } from 'fs/promises';
+import { rm, stat } from 'fs/promises';
import { join } from 'path';
import * as unzipper from 'unzipper';
@@ -317,6 +317,33 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expectError(invalidLimit, 400, 'limit must not be greater than 20');
});
+
+ it('filters books by the selected primary file size', async () => {
+ const primaryFileSize = (await stat(visibleEpub.absolutePath)).size;
+ const response = await ctx.app.inject({
+ method: 'POST',
+ url: '/api/v1/books/query',
+ headers: authHeader(limitedUser.accessToken),
+ payload: {
+ filter: {
+ type: 'group',
+ join: 'AND',
+ rules: [
+ { type: 'rule', field: 'title', operator: 'eq', value: 'Alpha Contract EPUB' },
+ { type: 'rule', field: 'fileSize', operator: 'eq', value: primaryFileSize },
+ ],
+ },
+ sort: [{ field: 'title', dir: 'asc' }],
+ pagination: { page: 0, size: 10 },
+ },
+ });
+
+ expect(response.statusCode).toBe(201);
+ expect(response.json()).toMatchObject({
+ total: 1,
+ items: [{ id: visibleEpub.bookId, files: [{ id: visibleEpub.bookFileId, role: 'primary', sizeBytes: primaryFileSize }] }],
+ });
+ });
});
describe('bulk query selections', () => {