diff --git a/Taskfile.yml b/Taskfile.yml index bcade4a0..af919fff 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -5,8 +5,8 @@ tasks: cmds: - go generate ./... - go run ./internal/dev gorm gen - - go run ./internal/gql/enums/gen/genenums.go - go run ./internal/torznab/gencategories/gencategories.go + - go run ./internal/gql/enums/gen/genenums.go - go run github.com/99designs/gqlgen generate --config ./internal/gql/gqlgen.yml - protoc --go_out=. ./internal/protobuf/bitmagnet.proto - go run github.com/vektra/mockery/v2 diff --git a/graphql/fragments/Torrent.graphql b/graphql/fragments/Torrent.graphql index 6769aa68..ddf02dbb 100644 --- a/graphql/fragments/Torrent.graphql +++ b/graphql/fragments/Torrent.graphql @@ -4,7 +4,6 @@ fragment Torrent on Torrent { infoHash name size - private filesStatus filesCount hasFilesInfo diff --git a/graphql/fragments/TorrentContent.graphql b/graphql/fragments/TorrentContent.graphql index fc817b7b..3d3d400b 100644 --- a/graphql/fragments/TorrentContent.graphql +++ b/graphql/fragments/TorrentContent.graphql @@ -28,6 +28,9 @@ fragment TorrentContent on TorrentContent { videoModifier videoResolution videoSource + seeders + leechers + publishedAt createdAt updatedAt } diff --git a/graphql/queries/TorrentContentSearch.graphql b/graphql/queries/TorrentContentSearch.graphql index f1c28503..6c8b62ef 100644 --- a/graphql/queries/TorrentContentSearch.graphql +++ b/graphql/queries/TorrentContentSearch.graphql @@ -3,9 +3,10 @@ query TorrentContentSearch( $query: SearchQueryInput $facets: TorrentContentFacetsInput + $orderBy: [TorrentContentOrderByInput!] ) { torrentContent { - search(query: $query, facets: $facets) { + search(query: $query, facets: $facets, orderBy: $orderBy) { ...TorrentContentSearchResult } } diff --git a/graphql/schema/enums.graphqls b/graphql/schema/enums.graphqls index 1ec70306..60d60cfe 100644 --- a/graphql/schema/enums.graphqls +++ b/graphql/schema/enums.graphqls @@ -145,3 +145,15 @@ enum VideoSource { WEBRip BluRay } + +enum TorrentContentOrderBy { + Relevance + PublishedAt + UpdatedAt + Size + Files + Seeders + Leechers + Name + InfoHash +} diff --git a/graphql/schema/models.graphqls b/graphql/schema/models.graphqls index d96840ec..7e1d39c3 100644 --- a/graphql/schema/models.graphqls +++ b/graphql/schema/models.graphqls @@ -2,7 +2,6 @@ type Torrent { infoHash: Hash20! name: String! size: Int! - private: Boolean! hasFilesInfo: Boolean! singleFile: Boolean extension: String @@ -56,6 +55,9 @@ type TorrentContent { video3d: Video3d videoModifier: VideoModifier releaseGroup: String + seeders: Int + leechers: Int + publishedAt: DateTime! createdAt: DateTime! updatedAt: DateTime! } diff --git a/graphql/schema/query.graphqls b/graphql/schema/query.graphqls index 1283ccda..0d4a0e2b 100644 --- a/graphql/schema/query.graphqls +++ b/graphql/schema/query.graphqls @@ -26,6 +26,7 @@ type TorrentContentQuery { search( query: SearchQueryInput facets: TorrentContentFacetsInput + orderBy: [TorrentContentOrderByInput!] ): TorrentContentSearchResult! } diff --git a/graphql/schema/search.graphqls b/graphql/schema/search.graphqls index b6e6d26f..e12b4db8 100644 --- a/graphql/schema/search.graphqls +++ b/graphql/schema/search.graphqls @@ -157,3 +157,8 @@ type TorrentContentSearchResult { items: [TorrentContent!]! aggregations: TorrentContentAggregations! } + +input TorrentContentOrderByInput { + field: TorrentContentOrderBy! + descending: Boolean +} diff --git a/internal/database/query/options.go b/internal/database/query/options.go index b2a9f0c5..b77f60e5 100644 --- a/internal/database/query/options.go +++ b/internal/database/query/options.go @@ -4,12 +4,10 @@ import ( "context" "github.com/bitmagnet-io/bitmagnet/internal/database/cache" "github.com/bitmagnet-io/bitmagnet/internal/database/dao" - "github.com/bitmagnet-io/bitmagnet/internal/database/fts" "github.com/bitmagnet-io/bitmagnet/internal/maps" "gorm.io/gen/field" "gorm.io/gorm/clause" "gorm.io/gorm/schema" - "strings" ) type Option = func(ctx OptionBuilder) (OptionBuilder, error) @@ -74,36 +72,8 @@ func RequireJoin(names ...string) Option { } func QueryString(str string) Option { - query := fts.AppQueryToTsquery(str) return func(ctx OptionBuilder) (OptionBuilder, error) { - if len(query) == 0 { - return ctx.Select(clause.Expr{ - SQL: "0 AS " + queryStringRankField, - }), nil - } - c, err := GenCriteria(func(ctx DbContext) (Criteria, error) { - return DbCriteria{ - Sql: strings.Join([]string{ - ctx.TableName() + ".tsv @@ ?::tsquery", - }, " OR "), - Args: []interface{}{ - query, - }, - }, nil - }).Raw(ctx) - if err != nil { - return ctx, err - } - ctx = ctx.Scope(func(dao SubQuery) error { - dao.UnderlyingDB().Where(c.Query, c.Args...) - return nil - }).RequireJoin(ctx.TableName()).Select(clause.Expr{ - SQL: "ts_rank_cd(" + ctx.TableName() + ".tsv, ?::tsquery) AS " + queryStringRankField, - Vars: []interface{}{ - query, - }, - }) - return ctx, nil + return ctx.QueryString(str), nil } } @@ -141,12 +111,12 @@ func OrderBy(columns ...clause.OrderByColumn) Option { } } -const queryStringRankField = "query_string_rank" +const QueryStringRankField = "query_string_rank" func OrderByQueryStringRank() Option { return func(ctx OptionBuilder) (OptionBuilder, error) { return ctx.OrderBy(clause.OrderByColumn{ - Column: clause.Column{Name: queryStringRankField}, + Column: clause.Column{Name: QueryStringRankField}, Desc: true, Reorder: true, }), nil diff --git a/internal/database/query/query.go b/internal/database/query/query.go index 7db298e9..a39a8901 100644 --- a/internal/database/query/query.go +++ b/internal/database/query/query.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/bitmagnet-io/bitmagnet/internal/database/dao" + "github.com/bitmagnet-io/bitmagnet/internal/database/fts" "github.com/bitmagnet-io/bitmagnet/internal/maps" "github.com/bitmagnet-io/bitmagnet/internal/model" "gorm.io/gen" @@ -207,6 +208,7 @@ type OptionBuilder interface { Table(string) OptionBuilder Join(...TableJoin) OptionBuilder RequireJoin(...string) OptionBuilder + QueryString(string) OptionBuilder Scope(...Scope) OptionBuilder Select(...clause.Expr) OptionBuilder OrderBy(...clause.OrderByColumn) OptionBuilder @@ -239,6 +241,7 @@ type optionBuilder struct { dbContext joins map[string]TableJoin requiredJoins maps.InsertMap[string, struct{}] + tsquery string scopes []Scope selections []clause.Expr groupBy []clause.Column @@ -302,6 +305,11 @@ func (b optionBuilder) RequireJoin(names ...string) OptionBuilder { return b } +func (b optionBuilder) QueryString(str string) OptionBuilder { + b.tsquery = fts.AppQueryToTsquery(str) + return b +} + func (b optionBuilder) Scope(scopes ...Scope) OptionBuilder { b.scopes = append(b.scopes, scopes...) return b @@ -318,7 +326,7 @@ func (b optionBuilder) Group(columns ...clause.Column) OptionBuilder { } func (b optionBuilder) OrderBy(columns ...clause.OrderByColumn) OptionBuilder { - b.orderBy = append(b.orderBy, columns...) + b.orderBy = columns return b } @@ -422,6 +430,19 @@ func (b optionBuilder) applySelect(sq SubQuery) error { selectQueryArgs = append(selectQueryArgs, s.Vars...) } } + for _, orderBy := range b.orderBy { + if orderBy.Column.Name == QueryStringRankField { + rankFragment := "0" + args := make([]interface{}, 0) + if b.tsquery != "" { + rankFragment = "ts_rank_cd(" + b.tableName + ".tsv, ?::tsquery)" + args = append(args, b.tsquery) + } + selectQueryParts = append(selectQueryParts, rankFragment+" AS "+QueryStringRankField) + selectQueryArgs = append(selectQueryArgs, args...) + break + } + } sq.UnderlyingDB().Select(strings.Join(selectQueryParts, ", "), selectQueryArgs...) return nil } @@ -432,6 +453,9 @@ func (b optionBuilder) applyPre(sq SubQuery) error { return err } } + if b.tsquery != "" { + sq.UnderlyingDB().Where(b.tableName+".tsv @@ ?::tsquery", b.tsquery) + } requiredJoins := b.requiredJoins.Copy() aggC, aggCErr := b.createFacetsFilterCriteria() if aggCErr != nil { @@ -502,19 +526,8 @@ func applyJoins(sq SubQuery, joins ...TableJoin) { func (b optionBuilder) applyPost(sq SubQuery) error { if len(b.orderBy) > 0 { - orderBy := make([]clause.OrderByColumn, 0, len(b.orderBy)) - for _, ob := range b.orderBy { - if ob.Reorder { - orderBy = append([]clause.OrderByColumn{{ - Column: ob.Column, - Desc: ob.Desc, - }}, orderBy...) - } else { - orderBy = append(orderBy, ob) - } - } sq.UnderlyingDB().Statement.AddClause(clause.OrderBy{ - Columns: orderBy, + Columns: b.orderBy, }) } if b.limit.Valid { diff --git a/internal/database/search/order_torrent_content.go b/internal/database/search/order_torrent_content.go new file mode 100644 index 00000000..770c0007 --- /dev/null +++ b/internal/database/search/order_torrent_content.go @@ -0,0 +1,140 @@ +package search + +import ( + "github.com/bitmagnet-io/bitmagnet/internal/database/query" + "github.com/bitmagnet-io/bitmagnet/internal/maps" + "github.com/bitmagnet-io/bitmagnet/internal/model" + "gorm.io/gorm/clause" +) + +//go:generate go run github.com/abice/go-enum --marshal --names --nocase --nocomments --sql --sqlnullstr --values -f order_torrent_content.go + +// TorrentContentOrderBy represents sort orders for torrent content search results +// ENUM(Relevance, PublishedAt, UpdatedAt, Size, Files, Seeders, Leechers, Name, InfoHash) +type TorrentContentOrderBy string + +// OrderDirection represents sort order directions +// ENUM(Ascending, Descending) +type OrderDirection string + +func (ob TorrentContentOrderBy) Clauses(direction OrderDirection) []clause.OrderByColumn { + desc := direction == OrderDirectionDescending + switch ob { + case TorrentContentOrderByRelevance: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Name: query.QueryStringRankField, + }, + Desc: desc, + }} + case TorrentContentOrderByPublishedAt: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Table: model.TableNameTorrentContent, + Name: "published_at", + }, + Desc: desc, + }} + case TorrentContentOrderByUpdatedAt: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Table: model.TableNameTorrentContent, + Name: "updated_at", + }, + Desc: desc, + }} + case TorrentContentOrderBySize: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Table: model.TableNameTorrent, + Name: "size", + }, + Desc: desc, + }} + case TorrentContentOrderByFiles: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Name: "CASE WHEN " + model.TableNameTorrent + ".files_status = 'single' THEN 1 ELSE COALESCE(" + model.TableNameTorrent + ".files_count, -1) END", + Raw: true, + }, + Desc: desc, + }} + case TorrentContentOrderBySeeders: + return []clause.OrderByColumn{ + { + Column: clause.Column{ + Name: model.TableNameTorrentContent + ".seeders IS NULL", + Raw: true, + }, + Desc: !desc, + }, + { + Column: clause.Column{ + Table: model.TableNameTorrentContent, + Name: "seeders", + }, + Desc: desc, + }, + } + case TorrentContentOrderByLeechers: + return []clause.OrderByColumn{ + { + Column: clause.Column{ + Name: model.TableNameTorrentContent + ".leechers IS NULL", + Raw: true, + }, + Desc: !desc, + }, + { + Column: clause.Column{ + Table: model.TableNameTorrentContent, + Name: "leechers", + }, + Desc: desc, + }, + } + case TorrentContentOrderByName: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Table: model.TableNameTorrent, + Name: "name", + }, + Desc: desc, + }} + case TorrentContentOrderByInfoHash: + return []clause.OrderByColumn{{ + Column: clause.Column{ + Table: model.TableNameTorrentContent, + Name: "info_hash", + }, + Desc: desc, + }} + default: + return []clause.OrderByColumn{} + } +} + +type TorrentContentFullOrderBy maps.InsertMap[TorrentContentOrderBy, OrderDirection] + +func (fob TorrentContentFullOrderBy) Clauses() []clause.OrderByColumn { + im := maps.InsertMap[TorrentContentOrderBy, OrderDirection](fob) + clauses := make([]clause.OrderByColumn, 0, im.Len()+2) + for _, ob := range im.Entries() { + clauses = append(clauses, ob.Key.Clauses(ob.Value)...) + } + // make ordering alphabetical and deterministic when not already specified: + if !im.Has(TorrentContentOrderByName) { + clauses = append(clauses, TorrentContentOrderByName.Clauses(OrderDirectionAscending)...) + } + if !im.Has(TorrentContentOrderByInfoHash) { + clauses = append(clauses, TorrentContentOrderByInfoHash.Clauses(OrderDirectionAscending)...) + } + return clauses +} + +func (fob TorrentContentFullOrderBy) Option() query.Option { + return query.Options( + query.RequireJoin(model.TableNameTorrent), + query.OrderBy(fob.Clauses()...), + ) +} diff --git a/internal/database/search/order_torrent_content_enum.go b/internal/database/search/order_torrent_content_enum.go new file mode 100644 index 00000000..5f903ded --- /dev/null +++ b/internal/database/search/order_torrent_content_enum.go @@ -0,0 +1,382 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: +// Revision: +// Build Date: +// Built By: + +package search + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + OrderDirectionAscending OrderDirection = "Ascending" + OrderDirectionDescending OrderDirection = "Descending" +) + +var ErrInvalidOrderDirection = fmt.Errorf("not a valid OrderDirection, try [%s]", strings.Join(_OrderDirectionNames, ", ")) + +var _OrderDirectionNames = []string{ + string(OrderDirectionAscending), + string(OrderDirectionDescending), +} + +// OrderDirectionNames returns a list of possible string values of OrderDirection. +func OrderDirectionNames() []string { + tmp := make([]string, len(_OrderDirectionNames)) + copy(tmp, _OrderDirectionNames) + return tmp +} + +// OrderDirectionValues returns a list of the values for OrderDirection +func OrderDirectionValues() []OrderDirection { + return []OrderDirection{ + OrderDirectionAscending, + OrderDirectionDescending, + } +} + +// String implements the Stringer interface. +func (x OrderDirection) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x OrderDirection) IsValid() bool { + _, err := ParseOrderDirection(string(x)) + return err == nil +} + +var _OrderDirectionValue = map[string]OrderDirection{ + "Ascending": OrderDirectionAscending, + "ascending": OrderDirectionAscending, + "Descending": OrderDirectionDescending, + "descending": OrderDirectionDescending, +} + +// ParseOrderDirection attempts to convert a string to a OrderDirection. +func ParseOrderDirection(name string) (OrderDirection, error) { + if x, ok := _OrderDirectionValue[name]; ok { + return x, nil + } + // Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to. + if x, ok := _OrderDirectionValue[strings.ToLower(name)]; ok { + return x, nil + } + return OrderDirection(""), fmt.Errorf("%s is %w", name, ErrInvalidOrderDirection) +} + +// MarshalText implements the text marshaller method. +func (x OrderDirection) MarshalText() ([]byte, error) { + return []byte(string(x)), nil +} + +// UnmarshalText implements the text unmarshaller method. +func (x *OrderDirection) UnmarshalText(text []byte) error { + tmp, err := ParseOrderDirection(string(text)) + if err != nil { + return err + } + *x = tmp + return nil +} + +var errOrderDirectionNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *OrderDirection) Scan(value interface{}) (err error) { + if value == nil { + *x = OrderDirection("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseOrderDirection(v) + case []byte: + *x, err = ParseOrderDirection(string(v)) + case OrderDirection: + *x = v + case *OrderDirection: + if v == nil { + return errOrderDirectionNilPtr + } + *x = *v + case *string: + if v == nil { + return errOrderDirectionNilPtr + } + *x, err = ParseOrderDirection(*v) + default: + return errors.New("invalid type for OrderDirection") + } + + return +} + +// Value implements the driver Valuer interface. +func (x OrderDirection) Value() (driver.Value, error) { + return x.String(), nil +} + +type NullOrderDirection struct { + OrderDirection OrderDirection + Valid bool + Set bool +} + +func NewNullOrderDirection(val interface{}) (x NullOrderDirection) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullOrderDirection) Scan(value interface{}) (err error) { + if value == nil { + x.OrderDirection, x.Valid = OrderDirection(""), false + return + } + + err = x.OrderDirection.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullOrderDirection) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.OrderDirection.String(), nil +} + +// MarshalJSON correctly serializes a NullOrderDirection to JSON. +func (n NullOrderDirection) MarshalJSON() ([]byte, error) { + const nullStr = "null" + if n.Valid { + return json.Marshal(n.OrderDirection) + } + return []byte(nullStr), nil +} + +// UnmarshalJSON correctly deserializes a NullOrderDirection from JSON. +func (n *NullOrderDirection) UnmarshalJSON(b []byte) error { + n.Set = true + var x interface{} + err := json.Unmarshal(b, &x) + if err != nil { + return err + } + err = n.Scan(x) + return err +} + +const ( + TorrentContentOrderByRelevance TorrentContentOrderBy = "Relevance" + TorrentContentOrderByPublishedAt TorrentContentOrderBy = "PublishedAt" + TorrentContentOrderByUpdatedAt TorrentContentOrderBy = "UpdatedAt" + TorrentContentOrderBySize TorrentContentOrderBy = "Size" + TorrentContentOrderByFiles TorrentContentOrderBy = "Files" + TorrentContentOrderBySeeders TorrentContentOrderBy = "Seeders" + TorrentContentOrderByLeechers TorrentContentOrderBy = "Leechers" + TorrentContentOrderByName TorrentContentOrderBy = "Name" + TorrentContentOrderByInfoHash TorrentContentOrderBy = "InfoHash" +) + +var ErrInvalidTorrentContentOrderBy = fmt.Errorf("not a valid TorrentContentOrderBy, try [%s]", strings.Join(_TorrentContentOrderByNames, ", ")) + +var _TorrentContentOrderByNames = []string{ + string(TorrentContentOrderByRelevance), + string(TorrentContentOrderByPublishedAt), + string(TorrentContentOrderByUpdatedAt), + string(TorrentContentOrderBySize), + string(TorrentContentOrderByFiles), + string(TorrentContentOrderBySeeders), + string(TorrentContentOrderByLeechers), + string(TorrentContentOrderByName), + string(TorrentContentOrderByInfoHash), +} + +// TorrentContentOrderByNames returns a list of possible string values of TorrentContentOrderBy. +func TorrentContentOrderByNames() []string { + tmp := make([]string, len(_TorrentContentOrderByNames)) + copy(tmp, _TorrentContentOrderByNames) + return tmp +} + +// TorrentContentOrderByValues returns a list of the values for TorrentContentOrderBy +func TorrentContentOrderByValues() []TorrentContentOrderBy { + return []TorrentContentOrderBy{ + TorrentContentOrderByRelevance, + TorrentContentOrderByPublishedAt, + TorrentContentOrderByUpdatedAt, + TorrentContentOrderBySize, + TorrentContentOrderByFiles, + TorrentContentOrderBySeeders, + TorrentContentOrderByLeechers, + TorrentContentOrderByName, + TorrentContentOrderByInfoHash, + } +} + +// String implements the Stringer interface. +func (x TorrentContentOrderBy) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x TorrentContentOrderBy) IsValid() bool { + _, err := ParseTorrentContentOrderBy(string(x)) + return err == nil +} + +var _TorrentContentOrderByValue = map[string]TorrentContentOrderBy{ + "Relevance": TorrentContentOrderByRelevance, + "relevance": TorrentContentOrderByRelevance, + "PublishedAt": TorrentContentOrderByPublishedAt, + "publishedat": TorrentContentOrderByPublishedAt, + "UpdatedAt": TorrentContentOrderByUpdatedAt, + "updatedat": TorrentContentOrderByUpdatedAt, + "Size": TorrentContentOrderBySize, + "size": TorrentContentOrderBySize, + "Files": TorrentContentOrderByFiles, + "files": TorrentContentOrderByFiles, + "Seeders": TorrentContentOrderBySeeders, + "seeders": TorrentContentOrderBySeeders, + "Leechers": TorrentContentOrderByLeechers, + "leechers": TorrentContentOrderByLeechers, + "Name": TorrentContentOrderByName, + "name": TorrentContentOrderByName, + "InfoHash": TorrentContentOrderByInfoHash, + "infohash": TorrentContentOrderByInfoHash, +} + +// ParseTorrentContentOrderBy attempts to convert a string to a TorrentContentOrderBy. +func ParseTorrentContentOrderBy(name string) (TorrentContentOrderBy, error) { + if x, ok := _TorrentContentOrderByValue[name]; ok { + return x, nil + } + // Case insensitive parse, do a separate lookup to prevent unnecessary cost of lowercasing a string if we don't need to. + if x, ok := _TorrentContentOrderByValue[strings.ToLower(name)]; ok { + return x, nil + } + return TorrentContentOrderBy(""), fmt.Errorf("%s is %w", name, ErrInvalidTorrentContentOrderBy) +} + +// MarshalText implements the text marshaller method. +func (x TorrentContentOrderBy) MarshalText() ([]byte, error) { + return []byte(string(x)), nil +} + +// UnmarshalText implements the text unmarshaller method. +func (x *TorrentContentOrderBy) UnmarshalText(text []byte) error { + tmp, err := ParseTorrentContentOrderBy(string(text)) + if err != nil { + return err + } + *x = tmp + return nil +} + +var errTorrentContentOrderByNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *TorrentContentOrderBy) Scan(value interface{}) (err error) { + if value == nil { + *x = TorrentContentOrderBy("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseTorrentContentOrderBy(v) + case []byte: + *x, err = ParseTorrentContentOrderBy(string(v)) + case TorrentContentOrderBy: + *x = v + case *TorrentContentOrderBy: + if v == nil { + return errTorrentContentOrderByNilPtr + } + *x = *v + case *string: + if v == nil { + return errTorrentContentOrderByNilPtr + } + *x, err = ParseTorrentContentOrderBy(*v) + default: + return errors.New("invalid type for TorrentContentOrderBy") + } + + return +} + +// Value implements the driver Valuer interface. +func (x TorrentContentOrderBy) Value() (driver.Value, error) { + return x.String(), nil +} + +type NullTorrentContentOrderBy struct { + TorrentContentOrderBy TorrentContentOrderBy + Valid bool + Set bool +} + +func NewNullTorrentContentOrderBy(val interface{}) (x NullTorrentContentOrderBy) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullTorrentContentOrderBy) Scan(value interface{}) (err error) { + if value == nil { + x.TorrentContentOrderBy, x.Valid = TorrentContentOrderBy(""), false + return + } + + err = x.TorrentContentOrderBy.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullTorrentContentOrderBy) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.TorrentContentOrderBy.String(), nil +} + +// MarshalJSON correctly serializes a NullTorrentContentOrderBy to JSON. +func (n NullTorrentContentOrderBy) MarshalJSON() ([]byte, error) { + const nullStr = "null" + if n.Valid { + return json.Marshal(n.TorrentContentOrderBy) + } + return []byte(nullStr), nil +} + +// UnmarshalJSON correctly deserializes a NullTorrentContentOrderBy from JSON. +func (n *NullTorrentContentOrderBy) UnmarshalJSON(b []byte) error { + n.Set = true + var x interface{} + err := json.Unmarshal(b, &x) + if err != nil { + return err + } + err = n.Scan(x) + return err +} diff --git a/internal/database/search/search_torrent_content.go b/internal/database/search/search_torrent_content.go index 80b01884..3c004a15 100644 --- a/internal/database/search/search_torrent_content.go +++ b/internal/database/search/search_torrent_content.go @@ -57,7 +57,7 @@ func TorrentContentDefaultOption() query.Option { clause.OrderByColumn{ Column: clause.Column{ Table: clause.CurrentTable, - Name: "id", + Name: "info_hash", }, }, ), diff --git a/internal/gql/enums/enums.go b/internal/gql/enums/enums.go index e0a02fd4..bc489bb7 100644 --- a/internal/gql/enums/enums.go +++ b/internal/gql/enums/enums.go @@ -1,6 +1,7 @@ package enums import ( + "github.com/bitmagnet-io/bitmagnet/internal/database/search" "github.com/bitmagnet-io/bitmagnet/internal/model" ) @@ -27,4 +28,5 @@ var Enums = []enum{ newEnum("VideoModifier", model.VideoModifierNames()), newEnum("VideoResolution", model.VideoResolutionNames()), newEnum("VideoSource", model.VideoSourceNames()), + newEnum("TorrentContentOrderBy", search.TorrentContentOrderByNames()), } diff --git a/internal/gql/gql.gen.go b/internal/gql/gql.gen.go index 2870dacc..6099f231 100644 --- a/internal/gql/gql.gen.go +++ b/internal/gql/gql.gen.go @@ -182,7 +182,6 @@ type ComplexityRoot struct { Leechers func(childComplexity int) int MagnetUri func(childComplexity int) int Name func(childComplexity int) int - Private func(childComplexity int) int Seeders func(childComplexity int) int SingleFile func(childComplexity int) int Size func(childComplexity int) int @@ -201,7 +200,10 @@ type ComplexityRoot struct { ID func(childComplexity int) int InfoHash func(childComplexity int) int Languages func(childComplexity int) int + Leechers func(childComplexity int) int + PublishedAt func(childComplexity int) int ReleaseGroup func(childComplexity int) int + Seeders func(childComplexity int) int Title func(childComplexity int) int Torrent func(childComplexity int) int UpdatedAt func(childComplexity int) int @@ -225,7 +227,7 @@ type ComplexityRoot struct { } TorrentContentQuery struct { - Search func(childComplexity int, query *query.SearchParams, facets *gen.TorrentContentFacetsInput) int + Search func(childComplexity int, query *query.SearchParams, facets *gen.TorrentContentFacetsInput, orderBy []gen.TorrentContentOrderByInput) int } TorrentContentSearchResult struct { @@ -892,13 +894,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Torrent.Name(childComplexity), true - case "Torrent.private": - if e.complexity.Torrent.Private == nil { - break - } - - return e.complexity.Torrent.Private(childComplexity), true - case "Torrent.seeders": if e.complexity.Torrent.Seeders == nil { break @@ -1004,6 +999,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.TorrentContent.Languages(childComplexity), true + case "TorrentContent.leechers": + if e.complexity.TorrentContent.Leechers == nil { + break + } + + return e.complexity.TorrentContent.Leechers(childComplexity), true + + case "TorrentContent.publishedAt": + if e.complexity.TorrentContent.PublishedAt == nil { + break + } + + return e.complexity.TorrentContent.PublishedAt(childComplexity), true + case "TorrentContent.releaseGroup": if e.complexity.TorrentContent.ReleaseGroup == nil { break @@ -1011,6 +1020,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.TorrentContent.ReleaseGroup(childComplexity), true + case "TorrentContent.seeders": + if e.complexity.TorrentContent.Seeders == nil { + break + } + + return e.complexity.TorrentContent.Seeders(childComplexity), true + case "TorrentContent.title": if e.complexity.TorrentContent.Title == nil { break @@ -1140,7 +1156,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.TorrentContentQuery.Search(childComplexity, args["query"].(*query.SearchParams), args["facets"].(*gen.TorrentContentFacetsInput)), true + return e.complexity.TorrentContentQuery.Search(childComplexity, args["query"].(*query.SearchParams), args["facets"].(*gen.TorrentContentFacetsInput), args["orderBy"].([]gen.TorrentContentOrderByInput)), true case "TorrentContentSearchResult.aggregations": if e.complexity.TorrentContentSearchResult.Aggregations == nil { @@ -1490,6 +1506,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputSearchQueryInput, ec.unmarshalInputSuggestTagsQueryInput, ec.unmarshalInputTorrentContentFacetsInput, + ec.unmarshalInputTorrentContentOrderByInput, ec.unmarshalInputTorrentFileTypeFacetInput, ec.unmarshalInputTorrentSourceFacetInput, ec.unmarshalInputTorrentTagFacetInput, @@ -1739,12 +1756,23 @@ enum VideoSource { WEBRip BluRay } + +enum TorrentContentOrderBy { + Relevance + PublishedAt + UpdatedAt + Size + Files + Seeders + Leechers + Name + InfoHash +} `, BuiltIn: false}, {Name: "../../graphql/schema/models.graphqls", Input: `type Torrent { infoHash: Hash20! name: String! size: Int! - private: Boolean! hasFilesInfo: Boolean! singleFile: Boolean extension: String @@ -1798,6 +1826,9 @@ type TorrentContent { video3d: Video3d videoModifier: VideoModifier releaseGroup: String + seeders: Int + leechers: Int + publishedAt: DateTime! createdAt: DateTime! updatedAt: DateTime! } @@ -1908,6 +1939,7 @@ type TorrentContentQuery { search( query: SearchQueryInput facets: TorrentContentFacetsInput + orderBy: [TorrentContentOrderByInput!] ): TorrentContentSearchResult! } @@ -2080,6 +2112,11 @@ type TorrentContentSearchResult { items: [TorrentContent!]! aggregations: TorrentContentAggregations! } + +input TorrentContentOrderByInput { + field: TorrentContentOrderBy! + descending: Boolean +} `, BuiltIn: false}, } var parsedSchema = gqlparser.MustLoadSchema(sources...) @@ -2124,6 +2161,15 @@ func (ec *executionContext) field_TorrentContentQuery_search_args(ctx context.Co } } args["facets"] = arg1 + var arg2 []gen.TorrentContentOrderByInput + if tmp, ok := rawArgs["orderBy"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("orderBy")) + arg2, err = ec.unmarshalOTorrentContentOrderByInput2ᚕgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderByInputᚄ(ctx, tmp) + if err != nil { + return nil, err + } + } + args["orderBy"] = arg2 return args, nil } @@ -5487,50 +5533,6 @@ func (ec *executionContext) fieldContext_Torrent_size(ctx context.Context, field return fc, nil } -func (ec *executionContext) _Torrent_private(ctx context.Context, field graphql.CollectedField, obj *model.Torrent) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Torrent_private(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return obj.Private, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(bool) - fc.Result = res - return ec.marshalNBoolean2bool(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Torrent_private(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Torrent", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Boolean does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _Torrent_hasFilesInfo(ctx context.Context, field graphql.CollectedField, obj *model.Torrent) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Torrent_hasFilesInfo(ctx, field) if err != nil { @@ -6330,8 +6332,6 @@ func (ec *executionContext) fieldContext_TorrentContent_torrent(ctx context.Cont return ec.fieldContext_Torrent_name(ctx, field) case "size": return ec.fieldContext_Torrent_size(ctx, field) - case "private": - return ec.fieldContext_Torrent_private(ctx, field) case "hasFilesInfo": return ec.fieldContext_Torrent_hasFilesInfo(ctx, field) case "singleFile": @@ -6959,6 +6959,132 @@ func (ec *executionContext) fieldContext_TorrentContent_releaseGroup(ctx context return fc, nil } +func (ec *executionContext) _TorrentContent_seeders(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.TorrentContent) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TorrentContent_seeders(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Seeders, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(model.NullUint) + fc.Result = res + return ec.marshalOInt2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋmodelᚐNullUint(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TorrentContent_seeders(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TorrentContent", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TorrentContent_leechers(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.TorrentContent) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TorrentContent_leechers(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Leechers, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(model.NullUint) + fc.Result = res + return ec.marshalOInt2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋmodelᚐNullUint(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TorrentContent_leechers(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TorrentContent", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _TorrentContent_publishedAt(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.TorrentContent) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_TorrentContent_publishedAt(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.PublishedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(time.Time) + fc.Result = res + return ec.marshalNDateTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_TorrentContent_publishedAt(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "TorrentContent", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type DateTime does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _TorrentContent_createdAt(ctx context.Context, field graphql.CollectedField, obj *gqlmodel.TorrentContent) (ret graphql.Marshaler) { fc, err := ec.fieldContext_TorrentContent_createdAt(ctx, field) if err != nil { @@ -7520,7 +7646,7 @@ func (ec *executionContext) _TorrentContentQuery_search(ctx context.Context, fie }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return obj.Search(ctx, fc.Args["query"].(*query.SearchParams), fc.Args["facets"].(*gen.TorrentContentFacetsInput)) + return obj.Search(ctx, fc.Args["query"].(*query.SearchParams), fc.Args["facets"].(*gen.TorrentContentFacetsInput), fc.Args["orderBy"].([]gen.TorrentContentOrderByInput)) }) if err != nil { ec.Error(ctx, err) @@ -7773,6 +7899,12 @@ func (ec *executionContext) fieldContext_TorrentContentSearchResult_items(ctx co return ec.fieldContext_TorrentContent_videoModifier(ctx, field) case "releaseGroup": return ec.fieldContext_TorrentContent_releaseGroup(ctx, field) + case "seeders": + return ec.fieldContext_TorrentContent_seeders(ctx, field) + case "leechers": + return ec.fieldContext_TorrentContent_leechers(ctx, field) + case "publishedAt": + return ec.fieldContext_TorrentContent_publishedAt(ctx, field) case "createdAt": return ec.fieldContext_TorrentContent_createdAt(ctx, field) case "updatedAt": @@ -11698,6 +11830,40 @@ func (ec *executionContext) unmarshalInputTorrentContentFacetsInput(ctx context. return it, nil } +func (ec *executionContext) unmarshalInputTorrentContentOrderByInput(ctx context.Context, obj interface{}) (gen.TorrentContentOrderByInput, error) { + var it gen.TorrentContentOrderByInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"field", "descending"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "field": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("field")) + data, err := ec.unmarshalNTorrentContentOrderBy2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderBy(ctx, v) + if err != nil { + return it, err + } + it.Field = data + case "descending": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("descending")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.Descending = graphql.OmittableOf(data) + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputTorrentFileTypeFacetInput(ctx context.Context, obj interface{}) (gen.TorrentFileTypeFacetInput, error) { var it gen.TorrentFileTypeFacetInput asMap := map[string]interface{}{} @@ -12866,11 +13032,6 @@ func (ec *executionContext) _Torrent(ctx context.Context, sel ast.SelectionSet, if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } - case "private": - out.Values[i] = ec._Torrent_private(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } case "hasFilesInfo": out.Values[i] = ec._Torrent_hasFilesInfo(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -13031,6 +13192,15 @@ func (ec *executionContext) _TorrentContent(ctx context.Context, sel ast.Selecti out.Values[i] = ec._TorrentContent_videoModifier(ctx, field, obj) case "releaseGroup": out.Values[i] = ec._TorrentContent_releaseGroup(ctx, field, obj) + case "seeders": + out.Values[i] = ec._TorrentContent_seeders(ctx, field, obj) + case "leechers": + out.Values[i] = ec._TorrentContent_leechers(ctx, field, obj) + case "publishedAt": + out.Values[i] = ec._TorrentContent_publishedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createdAt": out.Values[i] = ec._TorrentContent_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -14807,6 +14977,21 @@ func (ec *executionContext) marshalNTorrentContentAggregations2githubᚗcomᚋbi return ec._TorrentContentAggregations(ctx, sel, &v) } +func (ec *executionContext) unmarshalNTorrentContentOrderBy2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderBy(ctx context.Context, v interface{}) (gen.TorrentContentOrderBy, error) { + var res gen.TorrentContentOrderBy + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNTorrentContentOrderBy2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderBy(ctx context.Context, sel ast.SelectionSet, v gen.TorrentContentOrderBy) graphql.Marshaler { + return v +} + +func (ec *executionContext) unmarshalNTorrentContentOrderByInput2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderByInput(ctx context.Context, v interface{}) (gen.TorrentContentOrderByInput, error) { + res, err := ec.unmarshalInputTorrentContentOrderByInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) marshalNTorrentContentQuery2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚐTorrentContentQuery(ctx context.Context, sel ast.SelectionSet, v gqlmodel.TorrentContentQuery) graphql.Marshaler { return ec._TorrentContentQuery(ctx, sel, &v) } @@ -15939,6 +16124,26 @@ func (ec *executionContext) unmarshalOTorrentContentFacetsInput2ᚖgithubᚗcom return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOTorrentContentOrderByInput2ᚕgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderByInputᚄ(ctx context.Context, v interface{}) ([]gen.TorrentContentOrderByInput, error) { + if v == nil { + return nil, nil + } + var vSlice []interface{} + if v != nil { + vSlice = graphql.CoerceList(v) + } + var err error + res := make([]gen.TorrentContentOrderByInput, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNTorrentContentOrderByInput2githubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋgqlᚋgqlmodelᚋgenᚐTorrentContentOrderByInput(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + func (ec *executionContext) marshalOTorrentFile2ᚕgithubᚗcomᚋbitmagnetᚑioᚋbitmagnetᚋinternalᚋmodelᚐTorrentFileᚄ(ctx context.Context, sel ast.SelectionSet, v []model.TorrentFile) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/internal/gql/gqlmodel/gen/model.gen.go b/internal/gql/gqlmodel/gen/model.gen.go index 2368ef6e..0555e5f5 100644 --- a/internal/gql/gqlmodel/gen/model.gen.go +++ b/internal/gql/gqlmodel/gen/model.gen.go @@ -3,6 +3,10 @@ package gen import ( + "fmt" + "io" + "strconv" + "github.com/99designs/gqlgen/graphql" "github.com/bitmagnet-io/bitmagnet/internal/model" ) @@ -95,6 +99,11 @@ type TorrentContentFacetsInput struct { VideoSource graphql.Omittable[*VideoSourceFacetInput] `json:"videoSource,omitempty"` } +type TorrentContentOrderByInput struct { + Field TorrentContentOrderBy `json:"field"` + Descending graphql.Omittable[*bool] `json:"descending,omitempty"` +} + type TorrentFileTypeAgg struct { Value model.FileType `json:"value"` Label string `json:"label"` @@ -157,3 +166,58 @@ type VideoSourceFacetInput struct { Aggregate graphql.Omittable[*bool] `json:"aggregate,omitempty"` Filter graphql.Omittable[[]*model.VideoSource] `json:"filter,omitempty"` } + +type TorrentContentOrderBy string + +const ( + TorrentContentOrderByRelevance TorrentContentOrderBy = "Relevance" + TorrentContentOrderByPublishedAt TorrentContentOrderBy = "PublishedAt" + TorrentContentOrderByUpdatedAt TorrentContentOrderBy = "UpdatedAt" + TorrentContentOrderBySize TorrentContentOrderBy = "Size" + TorrentContentOrderByFiles TorrentContentOrderBy = "Files" + TorrentContentOrderBySeeders TorrentContentOrderBy = "Seeders" + TorrentContentOrderByLeechers TorrentContentOrderBy = "Leechers" + TorrentContentOrderByName TorrentContentOrderBy = "Name" + TorrentContentOrderByInfoHash TorrentContentOrderBy = "InfoHash" +) + +var AllTorrentContentOrderBy = []TorrentContentOrderBy{ + TorrentContentOrderByRelevance, + TorrentContentOrderByPublishedAt, + TorrentContentOrderByUpdatedAt, + TorrentContentOrderBySize, + TorrentContentOrderByFiles, + TorrentContentOrderBySeeders, + TorrentContentOrderByLeechers, + TorrentContentOrderByName, + TorrentContentOrderByInfoHash, +} + +func (e TorrentContentOrderBy) IsValid() bool { + switch e { + case TorrentContentOrderByRelevance, TorrentContentOrderByPublishedAt, TorrentContentOrderByUpdatedAt, TorrentContentOrderBySize, TorrentContentOrderByFiles, TorrentContentOrderBySeeders, TorrentContentOrderByLeechers, TorrentContentOrderByName, TorrentContentOrderByInfoHash: + return true + } + return false +} + +func (e TorrentContentOrderBy) String() string { + return string(e) +} + +func (e *TorrentContentOrderBy) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = TorrentContentOrderBy(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid TorrentContentOrderBy", str) + } + return nil +} + +func (e TorrentContentOrderBy) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/internal/gql/gqlmodel/torrent_content.go b/internal/gql/gqlmodel/torrent_content.go index 1d3cdee9..b32e54fb 100644 --- a/internal/gql/gqlmodel/torrent_content.go +++ b/internal/gql/gqlmodel/torrent_content.go @@ -5,6 +5,7 @@ import ( q "github.com/bitmagnet-io/bitmagnet/internal/database/query" "github.com/bitmagnet-io/bitmagnet/internal/database/search" "github.com/bitmagnet-io/bitmagnet/internal/gql/gqlmodel/gen" + "github.com/bitmagnet-io/bitmagnet/internal/maps" "github.com/bitmagnet-io/bitmagnet/internal/model" "github.com/bitmagnet-io/bitmagnet/internal/protocol" "time" @@ -30,6 +31,9 @@ type TorrentContent struct { VideoModifier model.NullVideoModifier ReleaseGroup model.NullString SearchString string + Seeders model.NullUint + Leechers model.NullUint + PublishedAt time.Time CreatedAt time.Time UpdatedAt time.Time Torrent model.Torrent @@ -55,6 +59,9 @@ func NewTorrentContentFromResultItem(item search.TorrentContentResultItem) Torre Video3d: item.Video3d, VideoModifier: item.VideoModifier, ReleaseGroup: item.ReleaseGroup, + Seeders: item.Seeders, + Leechers: item.Leechers, + PublishedAt: item.PublishedAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, Torrent: item.Torrent, @@ -105,12 +112,19 @@ type TorrentContentSearchResult struct { Aggregations gen.TorrentContentAggregations } -func (t TorrentContentQuery) Search(ctx context.Context, query *q.SearchParams, facets *gen.TorrentContentFacetsInput) (TorrentContentSearchResult, error) { +func (t TorrentContentQuery) Search( + ctx context.Context, + query *q.SearchParams, + facets *gen.TorrentContentFacetsInput, + orderBy []gen.TorrentContentOrderByInput, +) (TorrentContentSearchResult, error) { options := []q.Option{ search.TorrentContentDefaultOption(), } + hasQueryString := false if query != nil { options = append(options, query.Option()) + hasQueryString = query.QueryString.Valid } if facets != nil { var qFacets []q.Facet @@ -143,6 +157,22 @@ func (t TorrentContentQuery) Search(ctx context.Context, query *q.SearchParams, } options = append(options, q.WithFacet(qFacets...)) } + fullOrderBy := maps.NewInsertMap[search.TorrentContentOrderBy, search.OrderDirection]() + for _, ob := range orderBy { + if ob.Field == gen.TorrentContentOrderByRelevance && !hasQueryString { + continue + } + direction := search.OrderDirectionAscending + if desc, ok := ob.Descending.ValueOK(); ok && *desc { + direction = search.OrderDirectionDescending + } + field, err := search.ParseTorrentContentOrderBy(ob.Field.String()) + if err != nil { + return TorrentContentSearchResult{}, err + } + fullOrderBy.Set(field, direction) + } + options = append(options, search.TorrentContentFullOrderBy(fullOrderBy).Option()) result, resultErr := t.TorrentContentSearch.TorrentContent(ctx, options...) if resultErr != nil { return TorrentContentSearchResult{}, resultErr diff --git a/internal/maps/ordered.go b/internal/maps/ordered.go index 689c5986..513ff531 100644 --- a/internal/maps/ordered.go +++ b/internal/maps/ordered.go @@ -65,6 +65,11 @@ func (m InsertMap[K, V]) Get(key K) (V, bool) { return v, ok } +func (m InsertMap[K, V]) Has(key K) bool { + _, ok := m.keyValues[key] + return ok +} + func (m InsertMap[K, V]) Copy() InsertMap[K, V] { return NewInsertMap[K, V](m.Entries()...) } diff --git a/webui/dist/bitmagnet/index.html b/webui/dist/bitmagnet/index.html index fe26d221..15d12c85 100644 --- a/webui/dist/bitmagnet/index.html +++ b/webui/dist/bitmagnet/index.html @@ -9,5 +9,5 @@