From 1de4ed442cf37c14df3d24c55f172cb92c4cd736 Mon Sep 17 00:00:00 2001 From: Alberto Leal Date: Sat, 22 Mar 2025 19:25:32 -0400 Subject: [PATCH] feat: Torrent size filter --- graphql/schema/torrent_content.graphqls | 6 + .../search/criteria_torrent_content_size.go | 53 +++ .../criteria_torrent_content_size_test.go | 131 +++++++ internal/gql/gql.gen.go | 74 +++- internal/gql/gqlmodel/gen/model.gen.go | 6 + internal/gql/gqlmodel/torrent_content.go | 24 +- webui/src/app/graphql/generated/index.ts | 6 + webui/src/app/i18n/translations/en.json | 16 + .../torrents/torrents-search.component.html | 119 +++++++ .../torrents/torrents-search.component.scss | 35 ++ .../torrents-search.component.spec.ts | 5 +- .../app/torrents/torrents-search.component.ts | 329 +++++++++++++++++- .../torrents-search.controller.spec.ts | 123 +++++++ .../torrents/torrents-search.controller.ts | 20 ++ 14 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 internal/database/search/criteria_torrent_content_size.go create mode 100644 internal/database/search/criteria_torrent_content_size_test.go create mode 100644 webui/src/app/torrents/torrents-search.controller.spec.ts diff --git a/graphql/schema/torrent_content.graphqls b/graphql/schema/torrent_content.graphqls index 3c5b57cce..c31df619b 100644 --- a/graphql/schema/torrent_content.graphqls +++ b/graphql/schema/torrent_content.graphqls @@ -64,6 +64,11 @@ input VideoSourceFacetInput { filter: [VideoSource] } +input SizeRangeInput { + min: Int + max: Int +} + input TorrentContentFacetsInput { contentType: ContentTypeFacetInput torrentSource: TorrentSourceFacetInput @@ -74,6 +79,7 @@ input TorrentContentFacetsInput { releaseYear: ReleaseYearFacetInput videoResolution: VideoResolutionFacetInput videoSource: VideoSourceFacetInput + sizeRange: SizeRangeInput } type ContentTypeAgg { diff --git a/internal/database/search/criteria_torrent_content_size.go b/internal/database/search/criteria_torrent_content_size.go new file mode 100644 index 000000000..5a5b854ef --- /dev/null +++ b/internal/database/search/criteria_torrent_content_size.go @@ -0,0 +1,53 @@ +package search + +import ( + "fmt" + "strings" + "gorm.io/gorm" + "github.com/bitmagnet-io/bitmagnet/internal/database/query" + "github.com/bitmagnet-io/bitmagnet/internal/maps" +) + +type SizeRangeCriteria struct { + MinBytes *int64 + MaxBytes *int64 + Key string +} + +func (c SizeRangeCriteria) Apply(q *gorm.DB) (*gorm.DB, error) { + // If no min or max specified, return the query as is + if c.MinBytes == nil && c.MaxBytes == nil { + return q, nil + } + + if c.MinBytes != nil { + q = q.Where(fmt.Sprintf("%s >= ?", c.Key), *c.MinBytes) + } + + if c.MaxBytes != nil { + q = q.Where(fmt.Sprintf("%s <= ?", c.Key), *c.MaxBytes) + } + + return q, nil +} + +func (c SizeRangeCriteria) Raw(ctx query.DbContext) (query.RawCriteria, error) { + conditions := make([]string, 0, 2) + args := make([]interface{}, 0, 2) + + if c.MinBytes != nil { + conditions = append(conditions, fmt.Sprintf("%s >= ?", c.Key)) + args = append(args, *c.MinBytes) + } + + if c.MaxBytes != nil { + conditions = append(conditions, fmt.Sprintf("%s <= ?", c.Key)) + args = append(args, *c.MaxBytes) + } + + return query.RawCriteria{ + Query: strings.Join(conditions, " AND "), + Args: args, + Joins: maps.NewInsertMap[string, struct{}](), + }, nil +} \ No newline at end of file diff --git a/internal/database/search/criteria_torrent_content_size_test.go b/internal/database/search/criteria_torrent_content_size_test.go new file mode 100644 index 000000000..966d470b2 --- /dev/null +++ b/internal/database/search/criteria_torrent_content_size_test.go @@ -0,0 +1,131 @@ +package search + +import ( + "context" + "testing" + + "github.com/bitmagnet-io/bitmagnet/internal/database/dao" + "github.com/bitmagnet-io/bitmagnet/internal/database/query" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSizeRangeCriteria_Raw(t *testing.T) { + // Define test cases + tests := []struct { + name string + minBytes *int64 + maxBytes *int64 + key string + expectedQuery string + expectedArgs []interface{} + }{ + { + name: "no min or max specified", + minBytes: nil, + maxBytes: nil, + key: "torrent_contents.size", + expectedQuery: "", + expectedArgs: []interface{}{}, + }, + { + name: "only min specified", + minBytes: int64Ptr(1024), + maxBytes: nil, + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size >= ?", + expectedArgs: []interface{}{int64(1024)}, + }, + { + name: "only max specified", + minBytes: nil, + maxBytes: int64Ptr(1048576), + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size <= ?", + expectedArgs: []interface{}{int64(1048576)}, + }, + { + name: "both min and max specified", + minBytes: int64Ptr(1024), + maxBytes: int64Ptr(1048576), + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size >= ? AND torrent_contents.size <= ?", + expectedArgs: []interface{}{int64(1024), int64(1048576)}, + }, + { + name: "different column name", + minBytes: int64Ptr(1024), + maxBytes: int64Ptr(1048576), + key: "torrents.size", + expectedQuery: "torrents.size >= ? AND torrents.size <= ?", + expectedArgs: []interface{}{int64(1024), int64(1048576)}, + }, + { + name: "zero min value", + minBytes: int64Ptr(0), + maxBytes: int64Ptr(1048576), + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size >= ? AND torrent_contents.size <= ?", + expectedArgs: []interface{}{int64(0), int64(1048576)}, + }, + { + name: "min greater than max", + minBytes: int64Ptr(2048), + maxBytes: int64Ptr(1024), + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size >= ? AND torrent_contents.size <= ?", + expectedArgs: []interface{}{int64(2048), int64(1024)}, + }, + { + name: "large size values", + minBytes: int64Ptr(1_000_000_000), // 1GB + maxBytes: int64Ptr(1_000_000_000_000), // 1TB + key: "torrent_contents.size", + expectedQuery: "torrent_contents.size >= ? AND torrent_contents.size <= ?", + expectedArgs: []interface{}{int64(1_000_000_000), int64(1_000_000_000_000)}, + }, + } + + // Run tests + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + criteria := SizeRangeCriteria{ + MinBytes: tt.minBytes, + MaxBytes: tt.maxBytes, + Key: tt.key, + } + dbContext := &mockDBContext{} + + // Act + rawCriteria, err := criteria.Raw(dbContext) + + // Assert + require.NoError(t, err) + assert.Equal(t, tt.expectedQuery, rawCriteria.Query) + assert.Equal(t, tt.expectedArgs, rawCriteria.Args) + assert.NotNil(t, rawCriteria.Joins) + assert.Empty(t, rawCriteria.Joins.Entries(), "No joins should be required for size criteria") + }) + } +} + +// Helper function to create a pointer to an int64 +func int64Ptr(v int64) *int64 { + return &v +} + +// Mock DbContext for testing Raw method +type mockDBContext struct{} + +func (m *mockDBContext) Query() *dao.Query { + return nil +} + +func (m *mockDBContext) TableName() string { + return "" +} + +func (m *mockDBContext) NewSubQuery(ctx context.Context) query.SubQuery { + return nil +} \ No newline at end of file diff --git a/internal/gql/gql.gen.go b/internal/gql/gql.gen.go index 41fedd790..c7756bda6 100644 --- a/internal/gql/gql.gen.go +++ b/internal/gql/gql.gen.go @@ -2121,6 +2121,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputQueueMetricsQueryInput, ec.unmarshalInputQueuePurgeJobsInput, ec.unmarshalInputReleaseYearFacetInput, + ec.unmarshalInputSizeRangeInput, ec.unmarshalInputSuggestTagsQueryInput, ec.unmarshalInputTorrentContentFacetsInput, ec.unmarshalInputTorrentContentOrderByInput, @@ -2855,6 +2856,11 @@ input VideoSourceFacetInput { filter: [VideoSource] } +input SizeRangeInput { + min: Int + max: Int +} + input TorrentContentFacetsInput { contentType: ContentTypeFacetInput torrentSource: TorrentSourceFacetInput @@ -2865,6 +2871,7 @@ input TorrentContentFacetsInput { releaseYear: ReleaseYearFacetInput videoResolution: VideoResolutionFacetInput videoSource: VideoSourceFacetInput + sizeRange: SizeRangeInput } type ContentTypeAgg { @@ -16411,6 +16418,40 @@ func (ec *executionContext) unmarshalInputReleaseYearFacetInput(ctx context.Cont return it, nil } +func (ec *executionContext) unmarshalInputSizeRangeInput(ctx context.Context, obj any) (gen.SizeRangeInput, error) { + var it gen.SizeRangeInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"min", "max"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "min": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("min")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.Min = graphql.OmittableOf(data) + case "max": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("max")) + data, err := ec.unmarshalOInt2ᚖint(ctx, v) + if err != nil { + return it, err + } + it.Max = graphql.OmittableOf(data) + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputSuggestTagsQueryInput(ctx context.Context, obj any) (gen.SuggestTagsQueryInput, error) { var it gen.SuggestTagsQueryInput asMap := map[string]any{} @@ -16452,7 +16493,7 @@ func (ec *executionContext) unmarshalInputTorrentContentFacetsInput(ctx context. asMap[k] = v } - fieldsInOrder := [...]string{"contentType", "torrentSource", "torrentTag", "torrentFileType", "language", "genre", "releaseYear", "videoResolution", "videoSource"} + fieldsInOrder := [...]string{"contentType", "torrentSource", "torrentTag", "torrentFileType", "language", "genre", "releaseYear", "videoResolution", "videoSource", "sizeRange"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -16522,6 +16563,13 @@ func (ec *executionContext) unmarshalInputTorrentContentFacetsInput(ctx context. return it, err } it.VideoSource = graphql.OmittableOf(data) + case "sizeRange": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("sizeRange")) + data, err := ec.unmarshalOSizeRangeInput2ᚖgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐSizeRangeInput(ctx, v) + if err != nil { + return it, err + } + it.SizeRange = graphql.OmittableOf(data) } } @@ -22616,6 +22664,22 @@ func (ec *executionContext) marshalOInt2ᚕintᚄ(ctx context.Context, sel ast.S return ret } +func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v any) (*int, error) { + if v == nil { + return nil, nil + } + res, err := graphql.UnmarshalInt(v) + return &res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOInt2ᚖint(ctx context.Context, sel ast.SelectionSet, v *int) graphql.Marshaler { + if v == nil { + return graphql.Null + } + res := graphql.MarshalInt(*v) + return res +} + func (ec *executionContext) unmarshalOLanguage2ᚕgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋmodelᚐLanguageᚄ(ctx context.Context, v any) ([]model.Language, error) { if v == nil { return nil, nil @@ -23065,6 +23129,14 @@ func (ec *executionContext) unmarshalOReleaseYearFacetInput2ᚖgithubᚗcomᚋbi return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOSizeRangeInput2ᚖgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐSizeRangeInput(ctx context.Context, v any) (*gen.SizeRangeInput, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputSizeRangeInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOString2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋmodelᚐNullString(ctx context.Context, v any) (model.NullString, error) { var res model.NullString err := res.UnmarshalGQL(v) diff --git a/internal/gql/gqlmodel/gen/model.gen.go b/internal/gql/gqlmodel/gen/model.gen.go index f177022f4..56267e6ca 100644 --- a/internal/gql/gqlmodel/gen/model.gen.go +++ b/internal/gql/gqlmodel/gen/model.gen.go @@ -131,6 +131,11 @@ type ReleaseYearFacetInput struct { Filter graphql.Omittable[[]*model.Year] `json:"filter,omitempty"` } +type SizeRangeInput struct { + Min graphql.Omittable[*int] `json:"min,omitempty"` + Max graphql.Omittable[*int] `json:"max,omitempty"` +} + type SuggestTagsQueryInput struct { Prefix graphql.Omittable[*string] `json:"prefix,omitempty"` Exclusions graphql.Omittable[[]string] `json:"exclusions,omitempty"` @@ -158,6 +163,7 @@ type TorrentContentFacetsInput struct { ReleaseYear graphql.Omittable[*ReleaseYearFacetInput] `json:"releaseYear,omitempty"` VideoResolution graphql.Omittable[*VideoResolutionFacetInput] `json:"videoResolution,omitempty"` VideoSource graphql.Omittable[*VideoSourceFacetInput] `json:"videoSource,omitempty"` + SizeRange graphql.Omittable[*SizeRangeInput] `json:"sizeRange,omitempty"` } type TorrentContentOrderByInput struct { diff --git a/internal/gql/gqlmodel/torrent_content.go b/internal/gql/gqlmodel/torrent_content.go index 21ae873f3..0cb6434ad 100644 --- a/internal/gql/gqlmodel/torrent_content.go +++ b/internal/gql/gqlmodel/torrent_content.go @@ -162,7 +162,29 @@ func (t TorrentContentQuery) Search( qFacets = append(qFacets, videoSourceFacet(*videoSource)) } options = append(options, q.WithFacet(qFacets...)) + + // Handle size range filters + if sizeRange, ok := input.Facets.SizeRange.ValueOK(); ok { + sizeCriteria := search.SizeRangeCriteria{ + Key: "torrent_contents.size", + } + + if min, minOk := sizeRange.Min.ValueOK(); minOk { + minSize := int64(*min) + sizeCriteria.MinBytes = &minSize + } + + if max, maxOk := sizeRange.Max.ValueOK(); maxOk { + maxSize := int64(*max) + sizeCriteria.MaxBytes = &maxSize + } + + if sizeCriteria.MinBytes != nil || sizeCriteria.MaxBytes != nil { + options = append(options, q.Where(sizeCriteria)) + } + } } + if infoHashes, ok := input.InfoHashes.ValueOK(); ok { options = append(options, q.Where(search.TorrentContentInfoHashCriteria(infoHashes...))) } @@ -273,4 +295,4 @@ func transformTorrentContentAggregations(aggs q.Aggregations) (gen.TorrentConten a.VideoSource = agg } return a, nil -} +} \ No newline at end of file diff --git a/webui/src/app/graphql/generated/index.ts b/webui/src/app/graphql/generated/index.ts index 254ecd550..ce5fe4836 100644 --- a/webui/src/app/graphql/generated/index.ts +++ b/webui/src/app/graphql/generated/index.ts @@ -447,6 +447,11 @@ export type Season = { season: Scalars['Int']['output']; }; +export type SizeRangeInput = { + max?: InputMaybe; + min?: InputMaybe; +}; + export type SuggestTagsQueryInput = { exclusions?: InputMaybe>; prefix?: InputMaybe; @@ -523,6 +528,7 @@ export type TorrentContentFacetsInput = { genre?: InputMaybe; language?: InputMaybe; releaseYear?: InputMaybe; + sizeRange?: InputMaybe; torrentFileType?: InputMaybe; torrentSource?: InputMaybe; torrentTag?: InputMaybe; diff --git a/webui/src/app/i18n/translations/en.json b/webui/src/app/i18n/translations/en.json index 7f67182c3..905715ea0 100644 --- a/webui/src/app/i18n/translations/en.json +++ b/webui/src/app/i18n/translations/en.json @@ -106,6 +106,7 @@ }, "general": { "all": "All", + "apply": "Apply", "dismiss": "Dismiss", "error": "Error", "none": "None", @@ -236,6 +237,7 @@ "torrents": { "classification": "Classification", "clear_search": "Clear Search", + "clear_size_filter": "Clear Size Filter", "copy": "Copy", "copy_to_clipboard": "Copy to clipboard", "delete": "Delete", @@ -260,6 +262,9 @@ "leechers": "Leechers", "magnet": "Magnet", "magnet_links": "Magnet links", + "max_size": "Max Size", + "min_size": "Min Size", + "unit": "Unit", "new_tag": "New tag", "order_by": "Order by", "order_direction_toggle": "Toggle direction", @@ -292,6 +297,17 @@ "select_all": "Select All", "showing_x_of_y_files": "Showing {{x}} of {{y}} files", "size": "Size", + "size_filter": "Size Filter", + "size_units": { + "kb": "KB", + "mb": "MB", + "gb": "GB", + "tb": "TB", + "kib": "KiB", + "mib": "MiB", + "gib": "GiB", + "tib": "TiB" + }, "source": "Torrent Source", "summary": "Summary", "tags": { diff --git a/webui/src/app/torrents/torrents-search.component.html b/webui/src/app/torrents/torrents-search.component.html index d6d0ad14d..433d5fe2f 100644 --- a/webui/src/app/torrents/torrents-search.component.html +++ b/webui/src/app/torrents/torrents-search.component.html @@ -69,6 +69,125 @@ + + + + + + sd_card {{ t("torrents.size_filter") }} + + +
+
+ + {{ t("torrents.min_size") }} + + + + + {{ t("torrents.unit") }} + + {{ + t("torrents.size_units.kb") + }} + {{ + t("torrents.size_units.mb") + }} + {{ + t("torrents.size_units.gb") + }} + {{ + t("torrents.size_units.tb") + }} + {{ + t("torrents.size_units.kib") + }} + {{ + t("torrents.size_units.mib") + }} + {{ + t("torrents.size_units.gib") + }} + {{ + t("torrents.size_units.tib") + }} + + +
+ +
+ + {{ t("torrents.max_size") }} + + + + + {{ t("torrents.unit") }} + + {{ + t("torrents.size_units.kb") + }} + {{ + t("torrents.size_units.mb") + }} + {{ + t("torrents.size_units.gb") + }} + {{ + t("torrents.size_units.tb") + }} + {{ + t("torrents.size_units.kib") + }} + {{ + t("torrents.size_units.mib") + }} + {{ + t("torrents.size_units.gib") + }} + {{ + t("torrents.size_units.tib") + }} + + +
+ +
+ + + +
+
+
@for (facet of facets$ | async; track facet.key) { @if (facet.relevant) { { +describe("TorrentsSearchComponent", () => { let component: TorrentsSearchComponent; let fixture: ComponentFixture; diff --git a/webui/src/app/torrents/torrents-search.component.ts b/webui/src/app/torrents/torrents-search.component.ts index d3045ad7e..1a5f58ecc 100644 --- a/webui/src/app/torrents/torrents-search.component.ts +++ b/webui/src/app/torrents/torrents-search.component.ts @@ -94,6 +94,10 @@ export class TorrentsSearchComponent implements OnInit, OnDestroy { compactColumns = compactColumns; queryString = new FormControl(""); + minSizeControl = new FormControl(null); + maxSizeControl = new FormControl(null); + minSizeUnitControl = new FormControl("MiB"); + maxSizeUnitControl = new FormControl("MiB"); result = emptyResult; @@ -155,16 +159,131 @@ export class TorrentsSearchComponent implements OnInit, OnDestroy { ); } + // Helper function to convert size to bytes based on unit + private sizeToBytes(size: number | null, unit: string): number | undefined { + if (size === null) { + return undefined; + } + + let bytes: number; + switch (unit) { + // Standard SI units (KB, MB, GB, TB) - using 1000-based units + case "KB": + bytes = Math.floor(size * 1000); + break; + case "MB": + bytes = Math.floor(size * 1000 * 1000); + break; + case "GB": + bytes = Math.floor(size * 1000 * 1000) * 1000; + break; + case "TB": + bytes = Math.floor(size * 1000 * 1000) * 1000 * 1000; + break; + + // Binary units (KiB, MiB, GiB, TiB) - using 1024-based units + case "KiB": + bytes = Math.floor(size * 1024); + break; + case "MiB": + bytes = Math.floor(size * 1024 * 1024); + break; + case "GiB": + bytes = Math.floor(size * 1024 * 1024) * 1024; + break; + case "TiB": + bytes = Math.floor(size * 1024 * 1024) * 1024 * 1024; + break; + default: + bytes = size; + } + + return bytes; + } + + updateSizeFilter(): void { + const minSizeBytes = this.sizeToBytes( + this.minSizeControl.value, + this.minSizeUnitControl.value || "MiB", + ); + const maxSizeBytes = this.sizeToBytes( + this.maxSizeControl.value, + this.maxSizeUnitControl.value || "MiB", + ); + + // Update controller + this.controller.setSizeRange(minSizeBytes, maxSizeBytes); + + // Force a refresh + this.dataSource.refresh(); + } + + clearSizeFilter(): void { + // Reset form controls + this.minSizeControl.setValue(null); + this.maxSizeControl.setValue(null); + this.minSizeUnitControl.setValue("MiB"); + this.maxSizeUnitControl.setValue("MiB"); + + // Update controller + this.controller.setSizeRange(undefined, undefined); + + // Force a refresh + this.dataSource.refresh(); + } + ngOnInit(): void { this.subscriptions.push( this.route.queryParams.subscribe((params) => { + // Update query string this.queryString.setValue(stringParam(params, "query") ?? null); + + // Get size values + const minSize = intParam(params, "min_size"); + const maxSize = intParam(params, "max_size"); + + // Get units or defaults + const minSizeUnit = stringParam(params, "min_size_unit") || "MiB"; + const maxSizeUnit = stringParam(params, "max_size_unit") || "MiB"; + + // Set form values + if (minSize !== undefined) { + this.minSizeControl.setValue(minSize); + if ( + ["KB", "MB", "GB", "TB", "KiB", "MiB", "GiB", "TiB"].includes( + minSizeUnit, + ) + ) { + this.minSizeUnitControl.setValue(minSizeUnit); + } + } else { + this.minSizeControl.setValue(null); + } + + if (maxSize !== undefined) { + this.maxSizeControl.setValue(maxSize); + if ( + ["KB", "MB", "GB", "TB", "KiB", "MiB", "GiB", "TiB"].includes( + maxSizeUnit, + ) + ) { + this.maxSizeUnitControl.setValue(maxSizeUnit); + } + } else { + this.maxSizeControl.setValue(null); + } + + // Update controller with all params this.controller.update(() => paramsToControls(params)); }), this.controller.controls$.subscribe((ctrl) => { void this.router.navigate([], { relativeTo: this.route, - queryParams: controlsToParams(ctrl), + queryParams: controlsToParams( + ctrl, + this.minSizeUnitControl.value || "MiB", + this.maxSizeUnitControl.value || "MiB", + ), queryParamsHandling: "replace", }); }), @@ -217,6 +336,101 @@ const paramsToControls = (params: Params): TorrentSearchControls => { tab: torrentTabSelection, }; } + + // Handle size range parameters + const minSize = intParam(params, "min_size"); + const maxSize = intParam(params, "max_size"); + const minSizeUnit = stringParam(params, "min_size_unit") || "MiB"; + const maxSizeUnit = stringParam(params, "max_size_unit") || "MiB"; + + let minSizeBytes, maxSizeBytes; + + // Convert min size to bytes + if (minSize !== undefined) { + switch (minSizeUnit) { + // Standard SI units (KB, MB, GB, TB) - using 1000-based units + case "KB": + minSizeBytes = minSize * 1000; + break; + case "MB": + minSizeBytes = minSize * 1000 * 1000; + break; + case "GB": + // For GB values, calculate more carefully to avoid integer overflow + minSizeBytes = minSize * 1000 * 1000 * 1000; + break; + case "TB": + // For TB values, calculate even more carefully + minSizeBytes = minSize * 1000 * 1000 * 1000 * 1000; + break; + + // Binary units (KiB, MiB, GiB, TiB) - using 1024-based units + case "KiB": + minSizeBytes = minSize * 1024; + break; + case "MiB": + minSizeBytes = minSize * 1024 * 1024; + break; + case "GiB": + // For GiB values, calculate more carefully to avoid integer overflow + minSizeBytes = minSize * 1024 * 1024 * 1024; + break; + case "TiB": + // For TiB values, calculate even more carefully + minSizeBytes = minSize * 1024 * 1024 * 1024 * 1024; + break; + default: + minSizeBytes = minSize * 1024 * 1024; // Default to MiB + } + } + + // Convert max size to bytes + if (maxSize !== undefined) { + switch (maxSizeUnit) { + // Standard SI units (KB, MB, GB, TB) - using 1000-based units + case "KB": + maxSizeBytes = maxSize * 1000; + break; + case "MB": + maxSizeBytes = maxSize * 1000 * 1000; + break; + case "GB": + // For GB values, calculate more carefully to avoid integer overflow + maxSizeBytes = maxSize * 1000 * 1000 * 1000; + break; + case "TB": + // For TB values, calculate even more carefully + maxSizeBytes = maxSize * 1000 * 1000 * 1000 * 1000; + break; + + // Binary units (KiB, MiB, GiB, TiB) - using 1024-based units + case "KiB": + maxSizeBytes = maxSize * 1024; + break; + case "MiB": + maxSizeBytes = maxSize * 1024 * 1024; + break; + case "GiB": + // For GiB values, calculate more carefully to avoid integer overflow + maxSizeBytes = maxSize * 1024 * 1024 * 1024; + break; + case "TiB": + // For TiB values, calculate even more carefully + maxSizeBytes = maxSize * 1024 * 1024 * 1024 * 1024; + break; + default: + maxSizeBytes = maxSize * 1024 * 1024; // Default to MiB + } + } + + const sizeRange = + minSize || maxSize + ? { + min: minSizeBytes, + max: maxSizeBytes, + } + : undefined; + return { queryString, orderBy: orderByParam(params, !!queryString), @@ -224,6 +438,7 @@ const paramsToControls = (params: Params): TorrentSearchControls => { limit: intParam(params, "limit") ?? defaultLimit, page: intParam(params, "page") ?? 1, selectedTorrent, + sizeRange, facets: facets.reduce((acc, facet) => { const active = activeFacets?.includes(facet.key) ?? false; const filter = stringListParam(params, facet.key); @@ -235,7 +450,11 @@ const paramsToControls = (params: Params): TorrentSearchControls => { }; }; -const controlsToParams = (ctrl: TorrentSearchControls): Params => { +const controlsToParams = ( + ctrl: TorrentSearchControls, + minSizeUnit = "MiB", + maxSizeUnit = "MiB", +): Params => { let page: number | undefined = ctrl.page; let limit: number | undefined = ctrl.limit; if (page === 1) { @@ -249,6 +468,111 @@ const controlsToParams = (ctrl: TorrentSearchControls): Params => { if (orderBy) { desc = orderBy.descending ? "1" : "0"; } + + // Handle size range params + let minSize: number | undefined; + let maxSize: number | undefined; + + if (ctrl.sizeRange) { + // Convert bytes back to the selected unit, handling large numbers carefully + if (ctrl.sizeRange.min) { + // Convert min bytes to selected unit + switch (minSizeUnit) { + // Standard SI units (KB, MB, GB, TB) - using 1000-based units + case "KB": + minSize = Math.round(ctrl.sizeRange.min / 1000); + break; + case "MB": + minSize = Math.round(ctrl.sizeRange.min / (1000 * 1000)); + break; + case "GB": + // More careful division for large numbers + minSize = Math.round(ctrl.sizeRange.min / 1000 / (1000 * 1000)); + break; + case "TB": + // Even more careful division + minSize = Math.round( + ctrl.sizeRange.min / (1000 * 1000) / (1000 * 1000), + ); + break; + + // Binary units (KiB, MiB, GiB, TiB) - using 1024-based units + case "KiB": + minSize = Math.round(ctrl.sizeRange.min / 1024); + break; + case "MiB": + minSize = Math.round(ctrl.sizeRange.min / (1024 * 1024)); + break; + case "GiB": + // More careful division for large numbers + minSize = Math.round(ctrl.sizeRange.min / 1024 / (1024 * 1024)); + break; + case "TiB": + // Even more careful division + minSize = Math.round( + ctrl.sizeRange.min / (1024 * 1024) / (1024 * 1024), + ); + break; + default: + minSize = ctrl.sizeRange.min; + } + } + + if (ctrl.sizeRange.max) { + // Convert max bytes to selected unit + switch (maxSizeUnit) { + // Standard SI units (KB, MB, GB, TB) - using 1000-based units + case "KB": + maxSize = Math.round(ctrl.sizeRange.max / 1000); + break; + case "MB": + maxSize = Math.round(ctrl.sizeRange.max / (1000 * 1000)); + break; + case "GB": + // More careful division for large numbers + maxSize = Math.round(ctrl.sizeRange.max / 1000 / (1000 * 1000)); + break; + case "TB": + // Even more careful division + maxSize = Math.round( + ctrl.sizeRange.max / (1000 * 1000) / (1000 * 1000), + ); + break; + + // Binary units (KiB, MiB, GiB, TiB) - using 1024-based units + case "KiB": + maxSize = Math.round(ctrl.sizeRange.max / 1024); + break; + case "MiB": + maxSize = Math.round(ctrl.sizeRange.max / (1024 * 1024)); + break; + case "GiB": + // More careful division for large numbers + maxSize = Math.round(ctrl.sizeRange.max / 1024 / (1024 * 1024)); + break; + case "TiB": + // Even more careful division + maxSize = Math.round( + ctrl.sizeRange.max / (1024 * 1024) / (1024 * 1024), + ); + break; + default: + maxSize = ctrl.sizeRange.max; + } + } + } + + // Only include size unit params if we have size values + const sizeParams = + minSize || maxSize + ? { + min_size: minSize, + max_size: maxSize, + min_size_unit: minSize ? minSizeUnit : undefined, + max_size_unit: maxSize ? maxSizeUnit : undefined, + } + : {}; + return { query: ctrl.queryString ? encodeURIComponent(ctrl.queryString) : undefined, page, @@ -256,6 +580,7 @@ const controlsToParams = (ctrl: TorrentSearchControls): Params => { content_type: ctrl.contentType, order: orderBy?.field, desc, + ...sizeParams, ...(ctrl.selectedTorrent ? { torrent: ctrl.selectedTorrent.infoHash, diff --git a/webui/src/app/torrents/torrents-search.controller.spec.ts b/webui/src/app/torrents/torrents-search.controller.spec.ts new file mode 100644 index 000000000..864c3974a --- /dev/null +++ b/webui/src/app/torrents/torrents-search.controller.spec.ts @@ -0,0 +1,123 @@ +import { + TorrentsSearchController, + defaultOrderBy, + TorrentSearchControls, + SizeRangeFilter, +} from "./torrents-search.controller"; + +describe("TorrentsSearchController", () => { + let controller: TorrentsSearchController; + + beforeEach(() => { + controller = new TorrentsSearchController({ + limit: 20, + page: 1, + contentType: null, + orderBy: defaultOrderBy, + facets: { + genre: { active: false }, + language: { active: false }, + fileType: { active: false }, + torrentSource: { active: false }, + torrentTag: { active: false }, + videoResolution: { active: false }, + videoSource: { active: false }, + }, + }); + }); + + describe("setSizeRange", () => { + it("should set the size range and update controls", () => { + // Set a size range + controller.setSizeRange(1024, 1048576); + + // Get the current controls + let currentControls = {} as TorrentSearchControls; + const subscription = controller.controls$.subscribe({ + next: (controls: TorrentSearchControls) => { + currentControls = controls; + }, + }); + + // Check if sizeRange property is set correctly + const sizeRange = currentControls.sizeRange as SizeRangeFilter; + expect(sizeRange).toEqual({ min: 1024, max: 1048576 }); + + // Check that page is reset to 1 + expect(currentControls.page).toBe(1); + + // Setting only min + controller.setSizeRange(1024, undefined); + const minOnlyRange = currentControls.sizeRange as SizeRangeFilter; + expect(minOnlyRange).toEqual({ min: 1024, max: undefined }); + + // Setting only max + controller.setSizeRange(undefined, 1048576); + const maxOnlyRange = currentControls.sizeRange as SizeRangeFilter; + expect(maxOnlyRange).toEqual({ + min: undefined, + max: 1048576, + }); + + // Setting to undefined should remove the size range filter + controller.setSizeRange(undefined, undefined); + expect(currentControls.sizeRange).toBeUndefined(); + + subscription.unsubscribe(); + }); + + it("should not set a size range if both min and max are undefined", () => { + controller.setSizeRange(undefined, undefined); + + let currentControls = {} as TorrentSearchControls; + const subscription = controller.controls$.subscribe({ + next: (controls: TorrentSearchControls) => { + currentControls = controls; + }, + }); + + expect(currentControls.sizeRange).toBeUndefined(); + + subscription.unsubscribe(); + }); + + it("should correctly convert query parameters to size range", () => { + // Manually trigger an update with these params + // Note: This is typically handled by the paramsToControls function + controller.update(() => ({ + limit: 20, + page: 1, + contentType: null, + orderBy: defaultOrderBy, + facets: { + genre: { active: false }, + language: { active: false }, + fileType: { active: false }, + torrentSource: { active: false }, + torrentTag: { active: false }, + videoResolution: { active: false }, + videoSource: { active: false }, + }, + sizeRange: { + min: 100 * 1024 * 1024, // 100 MiB in bytes + max: 1000 * 1024 * 1024 * 1024, // 1000 GiB in bytes + }, + })); + + let currentControls = {} as TorrentSearchControls; + const subscription = controller.controls$.subscribe({ + next: (controls: TorrentSearchControls) => { + currentControls = controls; + }, + }); + + // Check if values were converted correctly + expect(currentControls.sizeRange).toBeDefined(); + const sizeRange = currentControls.sizeRange as SizeRangeFilter; + expect(sizeRange.min).toBe(100 * 1024 * 1024); + expect(sizeRange.max).toBe(1000 * 1024 * 1024 * 1024); + + subscription.unsubscribe(); + }); + }); +}); diff --git a/webui/src/app/torrents/torrents-search.controller.ts b/webui/src/app/torrents/torrents-search.controller.ts index 49625585f..cb83d7084 100644 --- a/webui/src/app/torrents/torrents-search.controller.ts +++ b/webui/src/app/torrents/torrents-search.controller.ts @@ -36,6 +36,11 @@ const compareTorrentSelection = ( return a === b; }; +export type SizeRangeFilter = { + min?: number; + max?: number; +}; + export type TorrentSearchControls = { limit: number; page: number; @@ -51,6 +56,7 @@ export type TorrentSearchControls = { videoResolution: FacetInput; videoSource: FacetInput; }; + sizeRange?: SizeRangeFilter; selectedTorrent?: TorrentSelection; }; @@ -113,6 +119,12 @@ const controlsToQueryVariables = ( filter: ctrl.facets.videoSource.filter, } : undefined, + sizeRange: ctrl.sizeRange + ? { + min: ctrl.sizeRange.min ? Number(ctrl.sizeRange.min) : undefined, + max: ctrl.sizeRange.max ? Number(ctrl.sizeRange.max) : undefined, + } + : undefined, }, }, }); @@ -326,6 +338,14 @@ export class TorrentsSearchController { page: event.page, })); } + + setSizeRange(min?: number, max?: number) { + this.update((ctrl) => ({ + ...ctrl, + page: 1, + sizeRange: min || max ? { min, max } : undefined, + })); + } } export type FacetDefinition = {