mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
feat(releases): delete older than x (#924)
* feat: delete releases older than x * check timestamp * incomplete front end changes commiting changes from codespace to not lose them * change to dropdown with options * using int comparisons to avoid nightmares * Revert "using int comparisons to avoid nightmares" This reverts commit dc55966a73e9f6ad79ed28c3a3e0dbe0e35448a6. * suggestions by stacksmash76 come back to discord @stacksmash76 * Curves - a touch of warmth in our pixel realm * replace inline css with tailwind * remove unnecessary comment * align label with dropdown changed first paragraph to something more sensible * change font weight for duration label * padding changes * nitpicky * merged divs where possible * small adjustments for light theme * attempt to fix for postgres * refactor: split into component and add confirmation modal also restyle component * fix: go fmt --------- Co-authored-by: ze0s <43699394+zze0s@users.noreply.github.com>
This commit is contained in:
parent
1f76aa38f4
commit
f774831d76
6 changed files with 182 additions and 54 deletions
|
@ -18,6 +18,7 @@ import (
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ReleaseRepo struct {
|
type ReleaseRepo struct {
|
||||||
|
@ -585,6 +586,40 @@ func (repo *ReleaseRepo) Delete(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (repo *ReleaseRepo) DeleteOlder(ctx context.Context, duration int) error {
|
||||||
|
tx, err := repo.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
olderThanTimestamp := time.Now().UTC().Add(-time.Duration(duration) * time.Hour)
|
||||||
|
log.Debug().Msgf("Deleting releases older than: %v", olderThanTimestamp)
|
||||||
|
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM "release" WHERE timestamp < $1`, olderThanTimestamp)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedRows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error fetching rows affected")
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Deleted %d rows from release table", deletedRows)
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM release_action_status WHERE release_id NOT IN (SELECT id FROM "release")`)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error executing query")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return errors.Wrap(err, "error commit transaction delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (repo *ReleaseRepo) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) {
|
func (repo *ReleaseRepo) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) {
|
||||||
// TODO support non season episode shows
|
// TODO support non season episode shows
|
||||||
// if rls.Day > 0 {
|
// if rls.Day > 0 {
|
||||||
|
|
|
@ -34,6 +34,7 @@ type ReleaseRepo interface {
|
||||||
GetIndexerOptions(ctx context.Context) ([]string, error)
|
GetIndexerOptions(ctx context.Context) ([]string, error)
|
||||||
Stats(ctx context.Context) (*ReleaseStats, error)
|
Stats(ctx context.Context) (*ReleaseStats, error)
|
||||||
Delete(ctx context.Context) error
|
Delete(ctx context.Context) error
|
||||||
|
DeleteOlder(ctx context.Context, duration int) error
|
||||||
CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error)
|
CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error)
|
||||||
|
|
||||||
GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error)
|
GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error)
|
||||||
|
|
|
@ -20,6 +20,7 @@ type releaseService interface {
|
||||||
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
|
||||||
|
DeleteOlder(ctx context.Context, duration int) error
|
||||||
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
|
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,6 +42,7 @@ func (h releaseHandler) Routes(r chi.Router) {
|
||||||
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)
|
||||||
|
r.Delete("/older-than/{duration}", h.deleteOlder)
|
||||||
|
|
||||||
r.Route("/{releaseId}", func(r chi.Router) {
|
r.Route("/{releaseId}", func(r chi.Router) {
|
||||||
r.Post("/actions/{actionStatusId}/retry", h.retryAction)
|
r.Post("/actions/{actionStatusId}/retry", h.retryAction)
|
||||||
|
@ -193,6 +195,28 @@ func (h releaseHandler) deleteReleases(w http.ResponseWriter, r *http.Request) {
|
||||||
h.encoder.NoContent(w)
|
h.encoder.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h releaseHandler) deleteOlder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
durationStr := chi.URLParam(r, "duration")
|
||||||
|
duration, err := strconv.Atoi(durationStr)
|
||||||
|
if err != nil {
|
||||||
|
h.encoder.StatusResponse(w, http.StatusBadRequest, map[string]interface{}{
|
||||||
|
"code": "BAD_REQUEST_PARAMS",
|
||||||
|
"message": "Invalid duration",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.DeleteOlder(r.Context(), duration); err != nil {
|
||||||
|
h.encoder.StatusResponse(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"code": "INTERNAL_SERVER_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.encoder.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) {
|
func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) {
|
||||||
var (
|
var (
|
||||||
req *domain.ReleaseActionRetryReq
|
req *domain.ReleaseActionRetryReq
|
||||||
|
|
|
@ -26,7 +26,7 @@ type Service interface {
|
||||||
Store(ctx context.Context, release *domain.Release) error
|
Store(ctx context.Context, release *domain.Release) error
|
||||||
StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error
|
StoreReleaseActionStatus(ctx context.Context, actionStatus *domain.ReleaseActionStatus) error
|
||||||
Delete(ctx context.Context) error
|
Delete(ctx context.Context) error
|
||||||
|
DeleteOlder(ctx context.Context, duration int) error
|
||||||
Process(release *domain.Release)
|
Process(release *domain.Release)
|
||||||
ProcessMultiple(releases []*domain.Release)
|
ProcessMultiple(releases []*domain.Release)
|
||||||
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
|
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
|
||||||
|
@ -95,6 +95,10 @@ func (s *service) Delete(ctx context.Context) error {
|
||||||
return s.repo.Delete(ctx)
|
return s.repo.Delete(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) DeleteOlder(ctx context.Context, duration int) error {
|
||||||
|
return s.repo.DeleteOlder(ctx, duration)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *service) Process(release *domain.Release) {
|
func (s *service) Process(release *domain.Release) {
|
||||||
if release == nil {
|
if release == nil {
|
||||||
return
|
return
|
||||||
|
|
|
@ -196,6 +196,7 @@ export const APIClient = {
|
||||||
indexerOptions: () => appClient.Get<string[]>("api/release/indexers"),
|
indexerOptions: () => appClient.Get<string[]>("api/release/indexers"),
|
||||||
stats: () => appClient.Get<ReleaseStats>("api/release/stats"),
|
stats: () => appClient.Get<ReleaseStats>("api/release/stats"),
|
||||||
delete: () => appClient.Delete("api/release/all"),
|
delete: () => appClient.Delete("api/release/all"),
|
||||||
|
deleteOlder: (duration: number) => appClient.Delete(`api/release/older-than/${duration}`),
|
||||||
replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`)
|
replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`)
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
|
|
|
@ -3,84 +3,147 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
import { APIClient } from "@api/APIClient";
|
import { APIClient } from "@api/APIClient";
|
||||||
import Toast from "@components/notifications/Toast";
|
import Toast from "@components/notifications/Toast";
|
||||||
|
import { releaseKeys } from "@screens/releases/ReleaseTable";
|
||||||
import { useToggle } from "@hooks/hooks";
|
import { useToggle } from "@hooks/hooks";
|
||||||
import { DeleteModal } from "@components/modals";
|
import { DeleteModal } from "@components/modals";
|
||||||
import { releaseKeys } from "@screens/releases/ReleaseTable";
|
|
||||||
|
|
||||||
function ReleaseSettings() {
|
function ReleaseSettings() {
|
||||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: APIClient.release.delete,
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.custom((t) => (
|
|
||||||
<Toast type="success" body={"All releases were deleted"} t={t}/>
|
|
||||||
));
|
|
||||||
|
|
||||||
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
|
||||||
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteAction = () => deleteMutation.mutate();
|
|
||||||
|
|
||||||
const cancelModalButtonRef = useRef(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<div className="lg:col-span-9">
|
||||||
className="lg:col-span-9"
|
|
||||||
action="#"
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
<DeleteModal
|
|
||||||
isOpen={deleteModalIsOpen}
|
|
||||||
toggle={toggleDeleteModal}
|
|
||||||
buttonRef={cancelModalButtonRef}
|
|
||||||
deleteAction={deleteAction}
|
|
||||||
title={"Delete all releases"}
|
|
||||||
text="Are you sure you want to delete all releases? This action cannot be undone."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
|
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
|
||||||
Releases
|
Releases
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
Release settings. Reset state.
|
Manage release history.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pb-6 divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="py-6 px-4">
|
||||||
<div className="px-4 py-5 sm:p-0">
|
<div className="border border-red-500 rounded">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ textAlign: "center" }} className="text-lg leading-6 font-medium text-gray-900 dark:text-white">
|
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Danger zone</h2>
|
||||||
Danger Zone
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
</h3>
|
This will clear release history in your database
|
||||||
<p style={{ textAlign: "center" }} className="mt-1 text-sm text-gray-900 dark:text-white">This will clear all release history in your database.</p>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center p-2 mt-2 max-w-sm m-auto">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleDeleteModal}
|
|
||||||
className="w-full inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 hover:text-red-900 dark:text-white bg-red-100 dark:bg-red-800 hover:bg-red-200 dark:hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
|
|
||||||
>
|
|
||||||
Delete all releases
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||||
|
<DeleteReleases />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDurationLabel = (durationValue: number): string => {
|
||||||
|
const durationOptions: Record<number, string> = {
|
||||||
|
0: "all time",
|
||||||
|
1: "1 hour",
|
||||||
|
12: "12 hours",
|
||||||
|
24: "1 day",
|
||||||
|
168: "1 week",
|
||||||
|
720: "1 month",
|
||||||
|
2160: "3 months",
|
||||||
|
4320: "6 months",
|
||||||
|
8760: "1 year"
|
||||||
|
};
|
||||||
|
|
||||||
|
return durationOptions[durationValue] || "Invalid duration";
|
||||||
|
};
|
||||||
|
|
||||||
|
function DeleteReleases() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [duration, setDuration] = useState<string>("");
|
||||||
|
const [parsedDuration, setParsedDuration] = useState<number>(0);
|
||||||
|
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
||||||
|
|
||||||
|
const deleteOlderMutation = useMutation({
|
||||||
|
mutationFn: (duration: number) => APIClient.release.deleteOlder(duration),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (parsedDuration === 0) {
|
||||||
|
toast.custom((t) => (
|
||||||
|
<Toast type="success" body={"All releases were deleted."} t={t} />
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
toast.custom((t) => (
|
||||||
|
<Toast type="success" body={`Releases older than ${getDurationLabel(parsedDuration)} were deleted.`} t={t} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate filters just in case, most likely not necessary but can't hurt.
|
||||||
|
queryClient.invalidateQueries({ queryKey: releaseKeys.lists() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteOlderReleases = () => {
|
||||||
|
if (isNaN(parsedDuration) || parsedDuration < 0) {
|
||||||
|
toast.custom((t) => <Toast type="error" body={"Please select a valid duration."} t={t} />);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteOlderMutation.mutate(parsedDuration);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center rounded-md shadow-sm">
|
||||||
|
<DeleteModal
|
||||||
|
isOpen={deleteModalIsOpen}
|
||||||
|
toggle={toggleDeleteModal}
|
||||||
|
buttonRef={cancelModalButtonRef}
|
||||||
|
deleteAction={deleteOlderReleases}
|
||||||
|
title="Remove releases"
|
||||||
|
text={`Are you sure you want to remove releases older than ${getDurationLabel(parsedDuration)}? This action cannot be undone.`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="duration" className="flex flex-col">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">Delete</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">Delete releases older than select duration</p>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
name="duration"
|
||||||
|
id="duration"
|
||||||
|
className="focus:outline-none focus:ring-1 focus:ring-offset-0 focus:ring-blue-500 dark:focus:ring-blue-500 rounded-md sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||||
|
value={duration}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsedDuration = parseInt(e.target.value, 10);
|
||||||
|
setParsedDuration(parsedDuration);
|
||||||
|
setDuration(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select duration</option>
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="12">12 hours</option>
|
||||||
|
<option value="24">1 day</option>
|
||||||
|
<option value="168">1 week</option>
|
||||||
|
<option value="720">1 month</option>
|
||||||
|
<option value="2160">3 months</option>
|
||||||
|
<option value="4320">6 months</option>
|
||||||
|
<option value="8760">1 year</option>
|
||||||
|
<option value="0">Delete everything</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleDeleteModal}
|
||||||
|
className="ml-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-red-700 hover:text-red-800 dark:text-white bg-red-200 dark:bg-red-700 hover:bg-red-300 dark:hover:bg-red-800 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-red-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue