feat(web): search releases (#302)

* feat(releases): search in ui

* refactor(releases): optimize query recent releases
This commit is contained in:
Ludvig Lundgren 2022-06-14 01:51:33 +02:00 committed by GitHub
parent 38addb99e6
commit 258754643d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 153 additions and 13 deletions

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -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 {

View file

@ -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)
} }

View file

@ -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()}`);

View file

@ -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 }
); );

View file

@ -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"}
@ -139,3 +141,24 @@ 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>
);};

View file

@ -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
)) ))
)} )}