mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 00:39:13 +00:00
feat(web): search releases (#302)
* feat(releases): search in ui * refactor(releases): optimize query recent releases
This commit is contained in:
parent
38addb99e6
commit
258754643d
9 changed files with 153 additions and 13 deletions
|
@ -3,6 +3,7 @@ package database
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
@ -116,6 +117,11 @@ func (repo *ReleaseRepo) Find(ctx context.Context, params domain.ReleaseQueryPar
|
||||||
release.ActionStatus = statuses
|
release.ActionStatus = statuses
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("error finding releases")
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return releases, nextCursor, total, nil
|
return releases, nextCursor, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,6 +145,10 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
|
||||||
queryBuilder = queryBuilder.Where(sq.Lt{"r.id": params.Cursor})
|
queryBuilder = queryBuilder.Where(sq.Lt{"r.id": params.Cursor})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if params.Search != "" {
|
||||||
|
queryBuilder = queryBuilder.Where("r.torrent_name LIKE ?", fmt.Sprint("%", params.Search, "%"))
|
||||||
|
}
|
||||||
|
|
||||||
if params.Filters.Indexers != nil {
|
if params.Filters.Indexers != nil {
|
||||||
filter := sq.And{}
|
filter := sq.And{}
|
||||||
for _, v := range params.Filters.Indexers {
|
for _, v := range params.Filters.Indexers {
|
||||||
|
@ -154,6 +164,10 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
|
||||||
|
|
||||||
query, args, err := queryBuilder.ToSql()
|
query, args, err := queryBuilder.ToSql()
|
||||||
repo.log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
|
repo.log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
|
||||||
|
if err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("error building query")
|
||||||
|
return nil, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
res := make([]*domain.Release, 0)
|
res := make([]*domain.Release, 0)
|
||||||
|
|
||||||
|
@ -197,6 +211,82 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
|
||||||
return res, nextCursor, countItems, nil
|
return res, nextCursor, countItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *ReleaseRepo) FindRecent(ctx context.Context) ([]*domain.Release, error) {
|
||||||
|
tx, err := repo.db.BeginTx(ctx, &sql.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
releases, err := repo.findRecentReleases(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, release := range releases {
|
||||||
|
statuses, err := repo.attachActionStatus(ctx, tx, release.ID)
|
||||||
|
if err != nil {
|
||||||
|
return releases, err
|
||||||
|
}
|
||||||
|
release.ActionStatus = statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("error finding releases")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (repo *ReleaseRepo) findRecentReleases(ctx context.Context, tx *Tx) ([]*domain.Release, error) {
|
||||||
|
queryBuilder := repo.db.squirrel.
|
||||||
|
Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.title", "r.torrent_name", "r.size", "r.timestamp").
|
||||||
|
From("release r").
|
||||||
|
OrderBy("r.timestamp DESC").
|
||||||
|
Limit(10)
|
||||||
|
|
||||||
|
query, args, err := queryBuilder.ToSql()
|
||||||
|
repo.log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
|
||||||
|
if err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("error building query")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]*domain.Release, 0)
|
||||||
|
|
||||||
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("error fetching releases")
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err)
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var rls domain.Release
|
||||||
|
|
||||||
|
var indexer, filter sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &indexer, &filter, &rls.Protocol, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp); err != nil {
|
||||||
|
repo.log.Error().Stack().Err(err).Msg("release.find: error scanning data to struct")
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rls.Indexer = indexer.String
|
||||||
|
rls.FilterName = filter.String
|
||||||
|
|
||||||
|
res = append(res, &rls)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
||||||
|
|
||||||
query := `SELECT DISTINCT indexer FROM "release" UNION SELECT DISTINCT identifier indexer FROM indexer;`
|
query := `SELECT DISTINCT indexer FROM "release" UNION SELECT DISTINCT identifier indexer FROM indexer;`
|
||||||
|
@ -359,7 +449,6 @@ func (repo *ReleaseRepo) Delete(ctx context.Context) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
repo.log.Error().Stack().Err(err).Msg("error deleting all releases")
|
repo.log.Error().Stack().Err(err).Msg("error deleting all releases")
|
||||||
return err
|
return err
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -25,6 +25,7 @@ import (
|
||||||
type ReleaseRepo interface {
|
type ReleaseRepo interface {
|
||||||
Store(ctx context.Context, release *Release) (*Release, error)
|
Store(ctx context.Context, release *Release) (*Release, error)
|
||||||
Find(ctx context.Context, params ReleaseQueryParams) (res []*Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, params ReleaseQueryParams) (res []*Release, nextCursor int64, count int64, err error)
|
||||||
|
FindRecent(ctx context.Context) ([]*Release, error)
|
||||||
GetIndexerOptions(ctx context.Context) ([]string, error)
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error)
|
GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error)
|
||||||
Stats(ctx context.Context) (*ReleaseStats, error)
|
Stats(ctx context.Context) (*ReleaseStats, error)
|
||||||
|
|
|
@ -10,7 +10,7 @@ type encoder struct{}
|
||||||
|
|
||||||
type errorResponse struct {
|
type errorResponse struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) {
|
func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
type releaseService interface {
|
type releaseService interface {
|
||||||
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error)
|
||||||
|
FindRecent(ctx context.Context) (res []*domain.Release, err error)
|
||||||
GetIndexerOptions(ctx context.Context) ([]string, error)
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
||||||
Delete(ctx context.Context) error
|
Delete(ctx context.Context) error
|
||||||
|
@ -31,6 +32,7 @@ func newReleaseHandler(encoder encoder, service releaseService) *releaseHandler
|
||||||
|
|
||||||
func (h releaseHandler) Routes(r chi.Router) {
|
func (h releaseHandler) Routes(r chi.Router) {
|
||||||
r.Get("/", h.findReleases)
|
r.Get("/", h.findReleases)
|
||||||
|
r.Get("/recent", h.findRecentReleases)
|
||||||
r.Get("/stats", h.getStats)
|
r.Get("/stats", h.getStats)
|
||||||
r.Get("/indexers", h.getIndexerOptions)
|
r.Get("/indexers", h.getIndexerOptions)
|
||||||
r.Delete("/all", h.deleteReleases)
|
r.Delete("/all", h.deleteReleases)
|
||||||
|
@ -86,6 +88,7 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
indexer := vals["indexer"]
|
indexer := vals["indexer"]
|
||||||
|
|
||||||
pushStatus := r.URL.Query().Get("push_status")
|
pushStatus := r.URL.Query().Get("push_status")
|
||||||
|
search := r.URL.Query().Get("q")
|
||||||
|
|
||||||
query := domain.ReleaseQueryParams{
|
query := domain.ReleaseQueryParams{
|
||||||
Limit: uint64(limit),
|
Limit: uint64(limit),
|
||||||
|
@ -96,6 +99,7 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
Indexers []string
|
Indexers []string
|
||||||
PushStatus string
|
PushStatus string
|
||||||
}{Indexers: indexer, PushStatus: pushStatus},
|
}{Indexers: indexer, PushStatus: pushStatus},
|
||||||
|
Search: search,
|
||||||
}
|
}
|
||||||
|
|
||||||
releases, nextCursor, count, err := h.service.Find(r.Context(), query)
|
releases, nextCursor, count, err := h.service.Find(r.Context(), query)
|
||||||
|
@ -117,6 +121,23 @@ func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h releaseHandler) findRecentReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
releases, err := h.service.FindRecent(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.encoder.StatusNotFound(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := struct {
|
||||||
|
Data []*domain.Release `json:"data"`
|
||||||
|
}{
|
||||||
|
Data: releases,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.encoder.StatusResponse(r.Context(), w, ret, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
func (h releaseHandler) getIndexerOptions(w http.ResponseWriter, r *http.Request) {
|
func (h releaseHandler) getIndexerOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
stats, err := h.service.GetIndexerOptions(r.Context())
|
stats, err := h.service.GetIndexerOptions(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
type Service interface {
|
type Service interface {
|
||||||
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error)
|
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error)
|
||||||
|
FindRecent(ctx context.Context) ([]*domain.Release, error)
|
||||||
GetIndexerOptions(ctx context.Context) ([]string, error)
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
Stats(ctx context.Context) (*domain.ReleaseStats, error)
|
||||||
Store(ctx context.Context, release *domain.Release) error
|
Store(ctx context.Context, release *domain.Release) error
|
||||||
|
@ -49,6 +50,10 @@ func (s *service) Find(ctx context.Context, query domain.ReleaseQueryParams) (re
|
||||||
return s.repo.Find(ctx, query)
|
return s.repo.Find(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) FindRecent(ctx context.Context) (res []*domain.Release, err error) {
|
||||||
|
return s.repo.FindRecent(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) {
|
||||||
return s.repo.GetIndexerOptions(ctx)
|
return s.repo.GetIndexerOptions(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,9 +140,10 @@ export const APIClient = {
|
||||||
},
|
},
|
||||||
release: {
|
release: {
|
||||||
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
find: (query?: string) => appClient.Get<ReleaseFindResponse>(`api/release${query}`),
|
||||||
|
findRecent: () => appClient.Get<ReleaseFindResponse>("api/release/recent"),
|
||||||
findQuery: (offset?: number, limit?: number, filters?: Array<ReleaseFilter>) => {
|
findQuery: (offset?: number, limit?: number, filters?: Array<ReleaseFilter>) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (offset !== undefined)
|
if (offset !== undefined && offset > 0)
|
||||||
params.append("offset", offset.toString());
|
params.append("offset", offset.toString());
|
||||||
|
|
||||||
if (limit !== undefined)
|
if (limit !== undefined)
|
||||||
|
@ -156,6 +157,8 @@ export const APIClient = {
|
||||||
params.append("indexer", filter.value);
|
params.append("indexer", filter.value);
|
||||||
else if (filter.id === "action_status")
|
else if (filter.id === "action_status")
|
||||||
params.append("push_status", filter.value);
|
params.append("push_status", filter.value);
|
||||||
|
else if (filter.id == "torrent_name")
|
||||||
|
params.append("q", filter.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
return appClient.Get<ReleaseFindResponse>(`api/release?${params.toString()}`);
|
return appClient.Get<ReleaseFindResponse>(`api/release?${params.toString()}`);
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { EmptyListState } from "../../components/emptystates";
|
||||||
|
|
||||||
import * as Icons from "../../components/Icons";
|
import * as Icons from "../../components/Icons";
|
||||||
import * as DataTable from "../../components/data-table";
|
import * as DataTable from "../../components/data-table";
|
||||||
import { Fragment } from "react";
|
|
||||||
|
|
||||||
// This is a custom filter UI for selecting
|
// This is a custom filter UI for selecting
|
||||||
// a unique option from a list
|
// a unique option from a list
|
||||||
|
@ -180,8 +179,8 @@ export const ActivityTable = () => {
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
const { isLoading, data } = useQuery(
|
const { isLoading, data } = useQuery(
|
||||||
"dash_release",
|
"dash_recent_releases",
|
||||||
() => APIClient.release.find("?limit=10"),
|
() => APIClient.release.findRecent(),
|
||||||
{ refetchOnWindowFocus: false }
|
{ refetchOnWindowFocus: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { APIClient } from "../../api/APIClient";
|
||||||
import { classNames } from "../../utils";
|
import { classNames } from "../../utils";
|
||||||
import { PushStatusOptions } from "../../domain/constants";
|
import { PushStatusOptions } from "../../domain/constants";
|
||||||
import { FilterProps } from "react-table";
|
import { FilterProps } from "react-table";
|
||||||
|
import { DebounceInput } from "react-debounce-input";
|
||||||
|
|
||||||
interface ListboxFilterProps {
|
interface ListboxFilterProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -77,6 +78,7 @@ export const IndexerSelectColumnFilter = ({
|
||||||
return (
|
return (
|
||||||
<ListboxFilter
|
<ListboxFilter
|
||||||
id={id}
|
id={id}
|
||||||
|
key={id}
|
||||||
label={filterValue ?? "Indexer"}
|
label={filterValue ?? "Indexer"}
|
||||||
currentValue={filterValue}
|
currentValue={filterValue}
|
||||||
onChange={setFilter}
|
onChange={setFilter}
|
||||||
|
@ -126,7 +128,7 @@ export const PushStatusSelectColumnFilter = ({
|
||||||
}: FilterProps<object>) => {
|
}: FilterProps<object>) => {
|
||||||
const label = filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)?.label : "Push status";
|
const label = filterValue ? PushStatusOptions.find((o) => o.value === filterValue && o.value)?.label : "Push status";
|
||||||
return (
|
return (
|
||||||
<div className="mr-3">
|
<div className="mr-3" key={id}>
|
||||||
<ListboxFilter
|
<ListboxFilter
|
||||||
id={id}
|
id={id}
|
||||||
label={label ?? "Push status"}
|
label={label ?? "Push status"}
|
||||||
|
@ -138,4 +140,25 @@ export const PushStatusSelectColumnFilter = ({
|
||||||
))}
|
))}
|
||||||
</ListboxFilter>
|
</ListboxFilter>
|
||||||
</div>
|
</div>
|
||||||
);};
|
);};
|
||||||
|
|
||||||
|
export const SearchColumnFilter = ({
|
||||||
|
column: { filterValue, setFilter, id }
|
||||||
|
}: FilterProps<object>) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 mr-3 mt-1" key={id}>
|
||||||
|
<DebounceInput
|
||||||
|
minLength={2}
|
||||||
|
value={filterValue || undefined}
|
||||||
|
debounceTimeout={500}
|
||||||
|
onChange={e => {
|
||||||
|
setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely
|
||||||
|
}}
|
||||||
|
id="filter"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
className="relative w-full py-2 pl-3 pr-10 text-left bg-white dark:bg-gray-800 rounded-lg shadow-md cursor-default dark:text-gray-400 sm:text-sm border-none"
|
||||||
|
placeholder="Search releases..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);};
|
||||||
|
|
|
@ -22,7 +22,7 @@ import * as DataTable from "../../components/data-table";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IndexerSelectColumnFilter,
|
IndexerSelectColumnFilter,
|
||||||
PushStatusSelectColumnFilter
|
PushStatusSelectColumnFilter, SearchColumnFilter
|
||||||
} from "./Filters";
|
} from "./Filters";
|
||||||
|
|
||||||
type TableState = {
|
type TableState = {
|
||||||
|
@ -77,7 +77,8 @@ export const ReleaseTable = () => {
|
||||||
{
|
{
|
||||||
Header: "Release",
|
Header: "Release",
|
||||||
accessor: "torrent_name",
|
accessor: "torrent_name",
|
||||||
Cell: DataTable.TitleCell
|
Cell: DataTable.TitleCell,
|
||||||
|
Filter: SearchColumnFilter
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: "Actions",
|
Header: "Actions",
|
||||||
|
@ -185,9 +186,7 @@ export const ReleaseTable = () => {
|
||||||
{headerGroups.map((headerGroup) =>
|
{headerGroups.map((headerGroup) =>
|
||||||
headerGroup.headers.map((column) => (
|
headerGroup.headers.map((column) => (
|
||||||
column.Filter ? (
|
column.Filter ? (
|
||||||
<div className="mt-2 sm:mt-0" key={column.id}>
|
<React.Fragment key={column.id}>{column.render("Filter")}</React.Fragment>
|
||||||
<>{column.render("Filter")}</>
|
|
||||||
</div>
|
|
||||||
) : null
|
) : null
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue