feat(lists): integrate Omegabrr (#1885)

* feat(lists): integrate Omegabrr

* feat(lists): add missing lists index

* feat(lists): add db repo

* feat(lists): add db migrations

* feat(lists): labels

* feat(lists): url lists and more arrs

* fix(lists): db migrations client_id wrong type

* fix(lists): db fields

* feat(lists): create list form wip

* feat(lists): show in list and create

* feat(lists): update and delete

* feat(lists): trigger via webhook

* feat(lists): add webhook handler

* fix(arr): encode json to pointer

* feat(lists): rename endpoint to lists

* feat(lists): fetch tags from arr

* feat(lists): process plaintext lists

* feat(lists): add background refresh job

* run every 6th hour with a random start delay between 1-35 seconds

* feat(lists): refresh on save and improve logging

* feat(lists): cast arr client to pointer

* feat(lists): improve error handling

* feat(lists): reset shows field with match release

* feat(lists): filter opts all lists

* feat(lists): trigger on update if enabled

* feat(lists): update option for lists

* feat(lists): show connected filters in list

* feat(lists): missing listSvc dep

* feat(lists): cleanup

* feat(lists): typo arr list

* feat(lists): radarr include original

* feat(lists): rename ExcludeAlternateTitle to IncludeAlternateTitle

* fix(lists): arr client type conversion to pointer

* fix(actions): only log panic recover if err not nil

* feat(lists): show spinner on save

* feat(lists): show icon in filters list

* feat(lists): change icon color in filters list

* feat(lists): delete relations on filter delete
This commit is contained in:
ze0s 2024-12-25 13:23:37 +01:00 committed by GitHub
parent b68ae334ca
commit 221bc35371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 5025 additions and 254 deletions

View file

@ -296,6 +296,7 @@ export const APIClient = {
},
download_clients: {
getAll: () => appClient.Get<DownloadClient[]>("api/download_clients"),
getArrTags: (clientID: number) => appClient.Get<ArrTag[]>(`api/download_clients/${clientID}/arr/tags`),
create: (dc: DownloadClient) => appClient.Post("api/download_clients", {
body: dc
}),
@ -409,6 +410,22 @@ export const APIClient = {
body: notification
})
},
lists: {
list: () => appClient.Get<List[]>("api/lists"),
getByID: (id: number) => appClient.Get<List>(`api/lists/${id}`),
store: (list: List) => appClient.Post("api/lists", {
body: list
}),
update: (list: List) => appClient.Put(`api/lists/${list.id}`, {
body: list
}),
delete: (id: number) => appClient.Delete(`api/lists/${id}`),
refreshList: (id: number) => appClient.Post(`api/lists/${id}/refresh`),
refreshAll: () => appClient.Post(`api/lists/refresh`),
test: (list: List) => appClient.Post("api/lists/test", {
body: list
})
},
proxy: {
list: () => appClient.Get<Proxy[]>("api/proxy"),
getByID: (id: number) => appClient.Get<Proxy>(`api/proxy/${id}`),

View file

@ -11,12 +11,19 @@ import {
FeedKeys,
FilterKeys,
IndexerKeys,
IrcKeys, NotificationKeys, ProxyKeys,
IrcKeys, ListKeys, NotificationKeys, ProxyKeys,
ReleaseKeys,
SettingsKeys
} from "@api/query_keys";
import { ColumnFilter } from "@tanstack/react-table";
export const FiltersGetAllQueryOptions = () =>
queryOptions({
queryKey: FilterKeys.lists(),
queryFn: () => APIClient.filters.getAll(),
refetchOnWindowFocus: false
});
export const FiltersQueryOptions = (indexers: string[], sortOrder: string) =>
queryOptions({
queryKey: FilterKeys.list(indexers, sortOrder),
@ -92,6 +99,14 @@ export const DownloadClientsQueryOptions = () =>
queryFn: () => APIClient.download_clients.getAll(),
});
export const DownloadClientsArrTagsQueryOptions = (clientID: number) =>
queryOptions({
queryKey: DownloadClientKeys.arrTags(clientID),
queryFn: () => APIClient.download_clients.getArrTags(clientID),
retry: false,
enabled: clientID > 0,
});
export const NotificationsQueryOptions = () =>
queryOptions({
queryKey: NotificationKeys.lists(),
@ -163,3 +178,10 @@ export const ProxyByIdQueryOptions = (proxyId: number) =>
queryFn: async ({queryKey}) => await APIClient.proxy.getByID(queryKey[2]),
retry: false,
});
export const ListsQueryOptions = () =>
queryOptions({
queryKey: ListKeys.lists(),
queryFn: () => APIClient.lists.list(),
refetchOnWindowFocus: false
});

View file

@ -47,7 +47,8 @@ export const DownloadClientKeys = {
lists: () => [...DownloadClientKeys.all, "list"] as const,
// list: (indexers: string[], sortOrder: string) => [...clientKeys.lists(), { indexers, sortOrder }] as const,
details: () => [...DownloadClientKeys.all, "detail"] as const,
detail: (id: number) => [...DownloadClientKeys.details(), id] as const
detail: (id: number) => [...DownloadClientKeys.details(), id] as const,
arrTags: (id: number) => [...DownloadClientKeys.details(), id, "arr-tags"] as const
};
export const FeedKeys = {
@ -89,3 +90,10 @@ export const ProxyKeys = {
details: () => [...ProxyKeys.all, "detail"] as const,
detail: (id: number) => [...ProxyKeys.details(), id] as const
};
export const ListKeys = {
all: ["list"] as const,
lists: () => [...ListKeys.all, "list"] as const,
details: () => [...ListKeys.all, "detail"] as const,
detail: (id: number) => [...ListKeys.details(), id] as const
};

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { JSX } from "react";
import { Field as FormikField } from "formik";
import Select from "react-select";
import { Field, Label, Description } from "@headlessui/react";
@ -33,6 +34,7 @@ interface TextFieldWideProps {
placeholder?: string;
defaultValue?: string;
required?: boolean;
disabled?: boolean;
autoComplete?: string;
hidden?: boolean;
tooltip?: JSX.Element;
@ -46,6 +48,7 @@ export const TextFieldWide = ({
placeholder,
defaultValue,
required,
disabled,
autoComplete,
tooltip,
hidden,
@ -68,6 +71,7 @@ export const TextFieldWide = ({
value={defaultValue}
required={required}
validate={validate}
disabled={disabled}
>
{({ field, meta }: FieldProps) => (
<input
@ -76,11 +80,13 @@ export const TextFieldWide = ({
type="text"
value={field.value ? field.value : defaultValue ?? ""}
onChange={field.onChange}
disabled={disabled}
className={classNames(
meta.touched && meta.error
? "border-red-500 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
"block w-full shadow-sm sm:text-sm rounded-md border py-2.5 dark:text-gray-100",
disabled ? "bg-gray-200 dark:bg-gray-700" : "bg-gray-100 dark:bg-gray-850 "
)}
placeholder={placeholder}
hidden={hidden}
@ -273,7 +279,7 @@ export const SwitchGroupWide = ({
</div>
</Label>
{description && (
<Description className="text-sm text-gray-500 dark:text-gray-700">
<Description className="text-sm text-gray-500 dark:text-gray-500">
{description}
</Description>
)}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { JSX } from "react";
import { Field } from "formik";
import Select from "react-select";
import CreatableSelect from "react-select/creatable";
@ -11,6 +12,8 @@ import type { FieldProps } from "formik";
import { OptionBasicTyped } from "@domain/constants";
import * as common from "@components/inputs/common";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { MultiSelect as RMSC } from "react-multi-select-component";
import { MultiSelectOption } from "@components/inputs/select.tsx";
interface SelectFieldProps<T> {
name: string;
@ -228,3 +231,67 @@ export function SelectFieldBasic<T>({ name, label, help, placeholder, required,
</div>
);
}
export interface MultiSelectFieldProps {
name: string;
label: string;
help?: string;
placeholder?: string;
required?: boolean;
tooltip?: JSX.Element;
options: OptionBasicTyped<number>[];
}
interface ListFilterMultiSelectOption {
id: number;
name: string;
}
export function ListFilterMultiSelectField({ name, label, help, tooltip, options }: MultiSelectFieldProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
// disabled={disabled}
labelledBy={name}
// isCreatable={creatable}
// onCreateOption={handleNewField}
value={field.value && field.value.map((item: ListFilterMultiSelectOption) => ({
value: item.id,
label: item.name
}))}
onChange={(values: MultiSelectOption[]) => {
const item = values && values.map((i) => ({ id: i.value, name: i.label }));
setFieldValue(field.name, item);
}}
/>
</>
)}
</Field>
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
</div>
</div>
);
}

View file

@ -409,6 +409,49 @@ export const PushStatusOptions: OptionBasic[] = [
}
];
export const ListTypeOptions: OptionBasicTyped<ListType>[] = [
{
label: "Sonarr",
value: "SONARR"
},
{
label: "Radarr",
value: "RADARR"
},
{
label: "Lidarr",
value: "LIDARR"
},
{
label: "Readarr",
value: "READARR"
},
{
label: "Whisparr",
value: "WHISPARR"
},
{
label: "MDBList",
value: "MDBLIST"
},
{
label: "Trakt",
value: "TRAKT"
},
{
label: "Plaintext",
value: "PLAINTEXT"
},
{
label: "Steam",
value: "STEAM"
},
{
label: "Metacritic",
value: "METACRITIC"
},
];
export const NotificationTypeOptions: OptionBasicTyped<NotificationType>[] = [
{
label: "Discord",
@ -607,3 +650,48 @@ export const ProxyTypeOptions: OptionBasicTyped<ProxyType>[] = [
value: "SOCKS5"
},
];
export const ListsTraktOptions: OptionBasic[] = [
{
label: "Anticipated TV",
value: "https://api.autobrr.com/lists/trakt/anticipated-tv"
},
{
label: "Popular TV",
value: "https://api.autobrr.com/lists/trakt/popular-tv"
},
{
label: "Upcoming Movies",
value: "https://api.autobrr.com/lists/trakt/upcoming-movies"
},
{
label: "Upcoming BluRay",
value: "https://api.autobrr.com/lists/trakt/upcoming-bluray"
},
{
label: "Popular TV",
value: "https://api.autobrr.com/lists/trakt/popular-tv"
},
{
label: "Steven Lu",
value: "https://api.autobrr.com/lists/stevenlu"
},
];
export const ListsMetacriticOptions: OptionBasic[] = [
{
label: "Upcoming Albums",
value: "https://api.autobrr.com/lists/metacritic/upcoming-albums"
},
{
label: "New Albums",
value: "https://api.autobrr.com/lists/metacritic/new-albums"
}
];
export const ListsMDBListOptions: OptionBasic[] = [
{
label: "Latest TV Shows",
value: "https://mdblist.com/lists/garycrawfordgc/latest-tv-shows/json"
},
];

View file

@ -8,3 +8,5 @@ export { FilterAddForm } from "./filters/FilterAddForm";
export { DownloadClientAddForm, DownloadClientUpdateForm } from "./settings/DownloadClientForms";
export { IndexerAddForm, IndexerUpdateForm } from "./settings/IndexerForms";
export { IrcNetworkAddForm, IrcNetworkUpdateForm } from "./settings/IrcForms";
export { ListAddForm, ListUpdateForm } from "./settings/ListForms";

View file

@ -0,0 +1,969 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { Fragment, JSX, useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Select from "react-select";
import {
Field,
FieldProps,
Form,
Formik,
FormikErrors,
FormikValues,
useFormikContext
} from "formik";
import {
Dialog,
DialogPanel,
DialogTitle,
Listbox,
ListboxButton, ListboxOption, ListboxOptions,
Transition,
TransitionChild
} from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { APIClient } from "@api/APIClient";
import { ListKeys } from "@api/query_keys";
import { toast } from "@components/hot-toast";
import Toast from "@components/notifications/Toast";
import * as common from "@components/inputs/common";
import {
MultiSelectOption,
PasswordFieldWide,
SwitchGroupWide,
TextFieldWide
} from "@components/inputs";
import {
ListsMDBListOptions,
ListsMetacriticOptions,
ListsTraktOptions,
ListTypeOptions, OptionBasicTyped,
SelectOption
} from "@domain/constants";
import { DEBUG } from "@components/debug";
import {
DownloadClientsArrTagsQueryOptions,
DownloadClientsQueryOptions,
FiltersGetAllQueryOptions
} from "@api/queries";
import { classNames, sleep } from "@utils";
import {
ListFilterMultiSelectField,
SelectFieldCreatable
} from "@components/inputs/select_wide";
import { DocsTooltip } from "@components/tooltips/DocsTooltip";
import { MultiSelect as RMSC } from "react-multi-select-component";
import { useToggle } from "@hooks/hooks.ts";
import { DeleteModal } from "@components/modals";
interface ListAddFormValues {
name: string;
enabled: boolean;
}
interface AddFormProps {
isOpen: boolean;
toggle: () => void;
}
export function ListAddForm({ isOpen, toggle }: AddFormProps) {
const queryClient = useQueryClient();
const { data: clients } = useQuery(DownloadClientsQueryOptions());
const filterQuery = useQuery(FiltersGetAllQueryOptions());
const createMutation = useMutation({
mutationFn: (list: List) => APIClient.lists.store(list),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body="List added!" t={t}/>);
toggle();
},
onError: () => {
toast.custom((t) => <Toast type="error" body="List could not be added" t={t}/>);
}
});
const onSubmit = (formData: unknown) => createMutation.mutate(formData as List);
const validate = (values: ListAddFormValues) => {
const errors = {} as FormikErrors<FormikValues>;
if (!values.name)
errors.name = "Required";
return errors;
};
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-hidden"
open={isOpen}
onClose={toggle}
>
<div className="absolute inset-0 overflow-hidden">
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
<Formik
enableReinitialize={true}
initialValues={{
enabled: true,
type: "",
name: "",
client_id: 0,
url: "",
headers: [],
api_key: "",
filters: [],
match_release: false,
tags_included: [],
tags_excluded: [],
include_unmonitored: false,
include_alternate_titles: false,
}}
onSubmit={onSubmit}
validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Add List
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-200">
Auto update filters from lists and arrs.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<TextFieldWide
name="name"
label="Name"
required={true}
/>
<div className="flex items-center justify-between space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-900 dark:text-white"
>
Type
</label>
</div>
<div className="sm:col-span-2">
<Field name="type" type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<Select
{...field}
isClearable={true}
isSearchable={true}
components={{
Input: common.SelectInput,
Control: common.SelectControl,
Menu: common.SelectMenu,
Option: common.SelectOption,
IndicatorSeparator: common.IndicatorSeparator,
DropdownIndicator: common.DropdownIndicator
}}
placeholder="Choose a type"
styles={{
singleValue: (base) => ({
...base,
color: "unset"
})
}}
theme={(theme) => ({
...theme,
spacing: {
...theme.spacing,
controlHeight: 30,
baseUnit: 2
}
})}
value={field?.value && field.value.value}
onChange={(option: unknown) => {
// resetForm();
const opt = option as SelectOption;
// setFieldValue("name", option?.label ?? "")
setFieldValue(
field.name,
opt.value ?? ""
);
}}
options={ListTypeOptions}
/>
)}
</Field>
</div>
</div>
<SwitchGroupWide name="enabled" label="Enabled"/>
</div>
<ListTypeForm listType={values.type} clients={clients ?? []}/>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Filters
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select filters to update for this list.
</p>
</div>
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({ value: f.id, label: f.name })) ?? []} />
</div>
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4 sm:px-6">
<div className="space-x-3 flex justify-end">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
Cancel
</button>
<SubmitButton isPending={createMutation.isPending} isError={createMutation.isError} isSuccess={createMutation.isSuccess} />
</div>
</div>
<DEBUG values={values}/>
</Form>
)}
</Formik>
</div>
</TransitionChild>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}
interface UpdateFormProps<T> {
isOpen: boolean;
toggle: () => void;
data: T;
}
export function ListUpdateForm({ isOpen, toggle, data }: UpdateFormProps<List>) {
const cancelModalButtonRef = useRef<HTMLInputElement | null>(null);
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const queryClient = useQueryClient();
const clientsQuery = useQuery(DownloadClientsQueryOptions());
const filterQuery = useQuery(FiltersGetAllQueryOptions());
const mutation = useMutation({
mutationFn: (list: List) => APIClient.lists.update(list),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${data.name} was updated successfully`} t={t}/>);
sleep(1500);
toggle();
}
});
const onSubmit = (formData: unknown) => mutation.mutate(formData as List);
const deleteMutation = useMutation({
mutationFn: (listID: number) => APIClient.lists.delete(listID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom((t) => <Toast type="success" body={`${data.name} was deleted.`} t={t}/>);
}
});
const deleteAction = () => deleteMutation.mutate(data.id);
return (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
static
className="fixed inset-0 overflow-hidden"
open={isOpen}
onClose={toggle}
>
{deleteAction && (
<DeleteModal
isOpen={deleteModalIsOpen}
isLoading={false}
toggle={toggleDeleteModal}
buttonRef={cancelModalButtonRef}
deleteAction={deleteAction}
title={`Remove ${data.name}`}
text={`Are you sure you want to remove this ${data.name}? This action cannot be undone.`}
/>
)}
<div className="absolute inset-0 overflow-hidden">
<DialogPanel className="absolute inset-y-0 right-0 max-w-full flex">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-2xl dark:border-gray-700 border-l">
<Formik
enableReinitialize={true}
initialValues={{
id: data.id,
enabled: data.enabled,
type: data.type,
name: data.name,
client_id: data.client_id,
url: data.url,
headers: data.headers || [],
api_key: data.api_key,
filters: data.filters,
match_release: data.match_release,
tags_included: data.tags_included,
tags_excluded: data.tags_excluded,
include_unmonitored: data.include_unmonitored,
include_alternate_titles: data.include_alternate_titles,
}}
onSubmit={onSubmit}
// validate={validate}
>
{({ values }) => (
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-auto">
<div className="flex-1">
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
<div className="flex items-start justify-between space-x-3">
<div className="space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Update List
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-200">
Auto update filters from lists and arrs.
</p>
</div>
<div className="h-7 flex items-center">
<button
type="button"
className="bg-white dark:bg-gray-700 rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
onClick={toggle}
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true"/>
</button>
</div>
</div>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<TextFieldWide name="name" label="Name" required={true}/>
<TextFieldWide name="type" label="Type" required={true} disabled={true} />
<SwitchGroupWide name="enabled" label="Enabled"/>
<div className="space-y-2 divide-y divide-gray-200 dark:divide-gray-700">
<ListTypeForm listType={values.type} clients={clientsQuery.data ?? []}/>
</div>
<div className="flex flex-col space-y-4 py-6 sm:py-0 sm:space-y-0">
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Filters
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Select filters to update for this list.
</p>
</div>
<ListFilterMultiSelectField name="filters" label="Filters" options={filterQuery.data?.map(f => ({
value: f.id,
label: f.name
})) ?? []}/>
</div>
</div>
</div>
</div>
<div className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-4">
<div className="space-x-3 flex justify-between">
<button
type="button"
className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-white bg-red-100 dark:bg-red-700 hover:bg-red-200 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm"
onClick={toggleDeleteModal}
>
Remove
</button>
<div className="flex space-x-3">
<button
type="button"
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
onClick={toggle}
>
Cancel
</button>
<SubmitButton isPending={mutation.isPending} isError={mutation.isError} isSuccess={mutation.isSuccess} />
</div>
</div>
</div>
<DEBUG values={values}/>
</Form>
)}
</Formik>
</div>
</TransitionChild>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}
interface SubmitButtonProps {
isPending?: boolean;
isError?: boolean;
isSuccess?: boolean;
}
const SubmitButton = (props: SubmitButtonProps) => {
return (
<button
type="submit"
className={classNames(
// isTestSuccessful
// ? "text-green-500 border-green-500 bg-green-50"
// : isError
// ? "text-red-500 border-red-500 bg-red-50"
// : "border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-rose-700 active:bg-rose-700",
props.isPending ? "cursor-not-allowed" : "",
"mr-2 inline-flex items-center px-4 py-2 border font-medium rounded-md shadow-sm text-sm transition ease-in-out duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:border-blue-700 active:bg-blue-700"
)}
>
{props.isPending ? (
<>
<svg
className="animate-spin h-5 w-5 text-green-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span className="pl-2">Saving..</span>
</>
) : (
<span>Save</span>
)}
</button>
);
}
interface ListTypeFormProps {
listID?: number;
listType: string;
clients: DownloadClient[];
}
const ListTypeForm = (props: ListTypeFormProps) => {
const { setFieldValue } = useFormikContext();
const [prevActionType, setPrevActionType] = useState<string | null>(null);
const { listType } = props;
useEffect(() => {
// if (prevActionType !== null && prevActionType !== list.type && ListTypeOptions.map(l => l.value).includes(list.type)) {
if (prevActionType !== null && prevActionType !== listType && ListTypeOptions.map(l => l.value).includes(listType as ListType)) {
// Reset the client_id field value
setFieldValue(`client_id`, 0);
}
setPrevActionType(listType);
}, [listType, prevActionType, setFieldValue]);
switch (props.listType) {
case "RADARR":
return <ListTypeArr {...props} />;
case "SONARR":
return <ListTypeArr {...props} />;
case "LIDARR":
return <ListTypeArr {...props} />;
case "READARR":
return <ListTypeArr {...props} />;
case "WHISPARR":
return <ListTypeArr {...props} />;
case "TRAKT":
return <ListTypeTrakt />;
case "STEAM":
return <ListTypeSteam />;
case "METACRITIC":
return <ListTypeMetacritic />;
case "MDBLIST":
return <ListTypeMDBList />;
case "PLAINTEXT":
return <ListTypePlainText />;
default:
return null;
}
}
const FilterOptionCheckBoxes = (props: ListTypeFormProps) => {
switch (props.listType) {
case "RADARR":
case "SONARR":
return (
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
<SwitchGroupWide name="include_alternate_titles" label="Include Alternate Titles" description="Include alternate titles in the filter." />
</fieldset>
);
case "LIDARR":
case "WHISPARR":
case "READARR":
return (
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="include_unmonitored" label="Include Unmonitored" description="By default only monitored titles are filtered." />
</fieldset>
);
}
}
function ListTypeArr({ listType, clients }: ListTypeFormProps) {
const { values } = useFormikContext<List>();
useEffect(() => {
}, [values.client_id]);
const arrTagsQuery = useQuery(DownloadClientsArrTagsQueryOptions(values.client_id));
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Update filters from titles in Radarr, Sonarr, Lidarr, Readarr, or Whisparr.
</p>
</div>
<DownloadClientSelectCustom
name={`client_id`}
clients={clients}
clientType={listType}
/>
{values.client_id > 0 && (values.type === "RADARR" || values.type == "SONARR") && (
<>
<ListArrTagsMultiSelectField name="tags_included" label="Tags Included" options={arrTagsQuery.data?.map(f => ({
value: f.label,
label: f.label
})) ?? []}/>
<ListArrTagsMultiSelectField name="tags_excluded" label="Tags Excluded" options={arrTagsQuery.data?.map(f => ({
value: f.label,
label: f.label
})) ?? []}/>
</>
)}
<div className="space-y-1">
<FilterOptionCheckBoxes listType={listType} clients={[]}/>
</div>
</div>
)
}
function ListTypeTrakt() {
const { values } = useFormikContext<List>();
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Trakt list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Default Trakt lists. Override with your own."
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
{!values.url.startsWith("https://api.autobrr.com/") && (
<PasswordFieldWide
name="api_key"
label="API Key"
help="Trakt API Key. Required for private lists."
/>
)}
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypePlainText() {
const { values } = useFormikContext<List>();
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Trakt list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Default Trakt lists. Override with your own."
options={ListsTraktOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
{!values.url.startsWith("https://api.autobrr.com/") && (
<PasswordFieldWide
name="api_key"
label="API Key"
help="Trakt API Key. Required for private lists."
/>
)}
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypeSteam() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Follow Steam wishlists.
</p>
</div>
<TextFieldWide name="url" label="URL" help={"Steam Wishlist URL"} placeholder="https://store.steampowered.com/wishlist/id/USERNAME/wishlistdata"/>
</div>
)
}
function ListTypeMetacritic() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a Metacritic list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="Metacritic lists. Override with your own."
options={ListsMetacriticOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
function ListTypeMDBList() {
return (
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
<div className="px-4 space-y-1">
<DialogTitle className="text-lg font-medium text-gray-900 dark:text-white">
Source list
</DialogTitle>
<p className="text-sm text-gray-500 dark:text-gray-400">
Use a MDBList list or one of the default autobrr hosted lists.
</p>
</div>
<SelectFieldCreatable
name="url"
label="List URL"
help="MDBLists.com lists. Override with your own."
options={ListsMDBListOptions.map(u => ({ value: u.value, label: u.label, key: u.label }))}
/>
<div className="space-y-1">
<fieldset>
<legend className="sr-only">Settings</legend>
<SwitchGroupWide name="match_release" label="Match Release" description="Use Match Releases field. Uses Movies/Shows field by default." />
</fieldset>
</div>
</div>
)
}
interface DownloadClientSelectProps {
name: string;
clientType: string;
clients: DownloadClient[];
}
function DownloadClientSelectCustom({ name, clientType, clients }: DownloadClientSelectProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
Select Client
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
meta,
form: { setFieldValue }
}: FieldProps) => (
<Listbox
value={field.value}
onChange={(value) => setFieldValue(field?.name, value)}
>
{({ open }) => (
<>
{/*<Label className="block text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">*/}
{/* Client*/}
{/*</Label>*/}
<div className="relative">
<ListboxButton
className="block w-full shadow-sm sm:text-sm rounded-md border py-2 pl-3 pr-10 text-left focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-815 dark:text-gray-100">
<span className="block truncate">
{field.value
? clients.find((c) => c.id === field.value)?.name
: "Choose a client"}
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<ChevronUpDownIcon
className="h-5 w-5 text-gray-400 dark:text-gray-300"
aria-hidden="true"/>
</span>
</ListboxButton>
<Transition
show={open}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
static
className="absolute z-10 mt-1 w-full border border-gray-400 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm"
>
{clients
.filter((c) => c.type === clientType)
.map((client) => (
<ListboxOption
key={client.id}
className={({ focus }) => classNames(
focus
? "text-white dark:text-gray-100 bg-blue-600 dark:bg-gray-950"
: "text-gray-900 dark:text-gray-300",
"cursor-default select-none relative py-2 pl-3 pr-9"
)}
value={client.id}
>
{({ selected, focus }) => (
<>
<span
className={classNames(
selected ? "font-semibold" : "font-normal",
"block truncate"
)}
>
{client.name}
</span>
{selected ? (
<span
className={classNames(
focus ? "text-white dark:text-gray-100" : "text-blue-600 dark:text-blue-500",
"absolute inset-y-0 right-0 flex items-center pr-4"
)}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"/>
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
{meta.touched && meta.error && (
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)}
</div>
</>
)}
</Listbox>
)}
</Field>
</div>
</div>
);
}
export interface ListMultiSelectFieldProps {
name: string;
label: string;
help?: string;
placeholder?: string;
required?: boolean;
tooltip?: JSX.Element;
options: OptionBasicTyped<number | string>[];
}
export function ListArrTagsMultiSelectField({ name, label, help, tooltip, options }: ListMultiSelectFieldProps) {
return (
<div className="flex items-center space-y-1 p-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4">
<div>
<label
htmlFor={name}
className="block ml-px text-sm font-medium text-gray-900 dark:text-white"
>
<div className="flex">
{tooltip ? (
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
) : label}
</div>
</label>
</div>
<div className="sm:col-span-2">
<Field name={name} type="select">
{({
field,
form: { setFieldValue }
}: FieldProps) => (
<>
<RMSC
{...field}
options={options}
// disabled={disabled}
labelledBy={name}
// isCreatable={creatable}
// onCreateOption={handleNewField}
value={field.value && field.value.map((item: MultiSelectOption) => ({
value: item.value ? item.value : item,
label: item.label ? item.label : item
}))}
onChange={(values: Array<MultiSelectOption>) => {
const am = values && values.map((i) => i.value);
setFieldValue(field.name, am);
}}
/>
</>
)}
</Field>
{help && (
<p className="mt-2 text-sm text-gray-500" id={`${name}-description`}>{help}</p>
)}
</div>
</div>
);
}

View file

@ -29,7 +29,7 @@ import {
FeedsQueryOptions,
FilterByIdQueryOptions,
IndexersQueryOptions,
IrcQueryOptions,
IrcQueryOptions, ListsQueryOptions,
NotificationsQueryOptions,
ProxiesQueryOptions
} from "@api/queries";
@ -52,6 +52,7 @@ import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "@api/QueryClient";
import ProxySettings from "@screens/settings/Proxy";
import ListsSettings from "@screens/settings/Lists";
import { ErrorPage } from "@components/alerts";
@ -186,6 +187,13 @@ export const SettingsIrcRoute = createRoute({
component: IrcSettings
});
export const SettingsListsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'lists',
loader: (opts) => opts.context.queryClient.ensureQueryData(ListsQueryOptions()),
component: ListsSettings
});
export const SettingsFeedsRoute = createRoute({
getParentRoute: () => SettingsRoute,
path: 'feeds',
@ -364,7 +372,7 @@ export const RootRoute = createRootRouteWithContext<{
});
const filterRouteTree = FiltersRoute.addChildren([FilterIndexRoute, FilterGetByIdRoute.addChildren([FilterGeneralRoute, FilterMoviesTvRoute, FilterMusicRoute, FilterAdvancedRoute, FilterExternalRoute, FilterActionsRoute])])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
const settingsRouteTree = SettingsRoute.addChildren([SettingsIndexRoute, SettingsLogRoute, SettingsIndexersRoute, SettingsIrcRoute, SettingsListsRoute, SettingsFeedsRoute, SettingsClientsRoute, SettingsNotificationsRoute, SettingsApiRoute, SettingsProxiesRoute, SettingsReleasesRoute, SettingsAccountRoute])
const authenticatedTree = AuthRoute.addChildren([AuthIndexRoute.addChildren([DashboardRoute, filterRouteTree, ReleasesRoute, settingsRouteTree, LogsRoute])])
const routeTree = RootRoute.addChildren([
authenticatedTree,

View file

@ -4,6 +4,7 @@
*/
import {
BarsArrowDownIcon,
BellIcon,
ChatBubbleLeftRightIcon,
CogIcon,
@ -32,6 +33,7 @@ const subNavigation: NavTabType[] = [
{ name: "Indexers", href: "/settings/indexers", icon: KeyIcon },
{ name: "IRC", href: "/settings/irc", icon: ChatBubbleLeftRightIcon },
{ name: "Feeds", href: "/settings/feeds", icon: RssIcon },
{ name: "Lists", href: "/settings/lists", icon: BarsArrowDownIcon },
{ name: "Clients", href: "/settings/clients", icon: FolderArrowDownIcon },
{ name: "Notifications", href: "/settings/notifications", icon: BellIcon },
{ name: "API keys", href: "/settings/api", icon: KeyIcon },

View file

@ -25,7 +25,7 @@ import {
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
PencilSquareIcon,
PlusIcon,
PlusIcon, SparklesIcon,
TrashIcon
} from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon } from "@heroicons/react/24/solid";
@ -589,9 +589,9 @@ function FilterListItem({ filter, idx }: FilterListItemProps) {
params={{
filterId: filter.id
}}
className="transition w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
className="transition flex items-center w-full break-words whitespace-wrap text-sm font-bold text-gray-800 dark:text-gray-100 hover:text-black dark:hover:text-gray-350"
>
{filter.name}
{filter.name} {filter.is_auto_updated && <SparklesIcon title="This filter is automatically updated by a list" className="ml-1 w-4 h-4 text-amber-500 dark:text-amber-400" aria-hidden="true"/>}
</Link>
<div className="flex items-center flex-wrap">
<span className="mr-2 break-words whitespace-nowrap text-xs font-medium text-gray-600 dark:text-gray-400">

View file

@ -0,0 +1,196 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { PlusIcon } from "@heroicons/react/24/solid";
import { useToggle } from "@hooks/hooks";
import { APIClient } from "@api/APIClient";
import { ListKeys } from "@api/query_keys";
import { toast } from "@components/hot-toast";
import Toast from "@components/notifications/Toast";
import { Checkbox } from "@components/Checkbox";
import { ListsQueryOptions } from "@api/queries";
import { Section } from "@screens/settings/_components";
import { EmptySimple } from "@components/emptystates";
import { ListAddForm, ListUpdateForm } from "@forms";
import { FC } from "react";
import { Link } from "@tanstack/react-router";
function ListsSettings() {
const [addFormIsOpen, toggleAddList] = useToggle(false);
const listsQuery = useSuspenseQuery(ListsQueryOptions())
const lists = listsQuery.data
return (
<Section
title="Lists"
description={
<>
Lists can automatically update your filters from arrs or other sources.<br/>
</>
}
rightSide={
<button
type="button"
onClick={toggleAddList}
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
>
<PlusIcon className="h-5 w-5 mr-1"/>
Add new
</button>
}
>
<ListAddForm isOpen={addFormIsOpen} toggle={toggleAddList} />
<div className="flex flex-col">
{lists.length ? (
<ul className="min-w-full relative">
<li className="grid grid-cols-12 border-b border-gray-200 dark:border-gray-700">
<div
className="flex col-span-2 sm:col-span-1 pl-0 sm:pl-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Enabled
</div>
<div
className="col-span-5 sm:col-span-4 pl-12 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Name
</div>
<div
className="hidden md:flex col-span-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Filters
</div>
<div
className="hidden md:flex col-span-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 hover:dark:text-gray-250 transition-colors uppercase tracking-wider cursor-pointer"
>
Type
</div>
</li>
{lists.map((list) => (
<ListItem list={list} key={list.id}/>
))}
</ul>
) : (
<EmptySimple
title="No lists"
subtitle=""
buttonText="Add new list"
buttonAction={toggleAddList}
/>
)}
</div>
</Section>
);
}
interface FilterPillProps {
filter: ListFilter;
}
const FilterPill: FC<FilterPillProps> = ({ filter }) => (
<Link
className="hidden sm:inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400 hover:dark:bg-gray-750 hover:bg-gray-700"
to={`/filters/$filterId`}
params={{ filterId: filter.id }}
>
{filter.name}
</Link>
);
export default ListsSettings;
interface ListItemProps {
list: List;
}
function ListItem({ list }: ListItemProps) {
const [isOpen, toggleUpdate] = useToggle(false);
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (req: List) => APIClient.lists.update(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ListKeys.lists() });
toast.custom(t => <Toast type="success" body={`List ${list.name} was ${list.enabled ? "enabled" : "disabled"} successfully.`} t={t} />);
},
onError: () => {
toast.custom((t) => <Toast type="error" body="List state could not be updated" t={t} />);
}
});
const onToggleMutation = (newState: boolean) => {
updateMutation.mutate({
...list,
enabled: newState
});
};
return (
<li>
<ListUpdateForm isOpen={isOpen} toggle={toggleUpdate} data={list} />
<div className="grid grid-cols-12 items-center py-1.5">
<div className="col-span-2 sm:col-span-1 flex pl-1 sm:pl-5 items-center">
<Checkbox value={list.enabled ?? false} setValue={onToggleMutation}/>
</div>
<div
className="col-span-5 sm:col-span-4 pl-12 sm:pr-6 py-3 block flex-col text-sm font-medium text-gray-900 dark:text-white truncate">
{list.name}
</div>
<div
className="hidden md:block col-span-4 pr-6 py-3 space-x-1 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
{/*{list.filters.map(filter => <FilterPill filter={filter} key={filter.id} />)}*/}
<ListItemFilters filters={list.filters} />
</div>
<div
className="hidden md:block col-span-2 pr-6 py-3 text-left items-center whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 truncate">
{list.type}
</div>
<div className="col-span-1 flex first-letter:px-6 py-3 whitespace-nowrap text-right text-sm font-medium">
<span
className="col-span-1 px-6 text-blue-600 dark:text-gray-300 hover:text-blue-900 dark:hover:text-blue-500 cursor-pointer"
onClick={toggleUpdate}
>
Edit
</span>
</div>
</div>
</li>
);
}
interface ListItemFiltersProps {
filters: ListFilter[];
}
const ListItemFilters = ({ filters }: ListItemFiltersProps) => {
if (!filters.length) {
return null;
}
const res = filters.slice(2);
return (
<div className="flex flex-row gap-1">
<FilterPill filter={filters[0]} />
{filters.length > 1 ? (
<FilterPill filter={filters[1]} />
) : null}
{filters.length > 2 ? (
<span
className="mr-2 inline-flex items-center px-2 py-0.5 rounded-md text-sm font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"
title={res.map(v => v.name).toString()}
>
+{filters.length - 2}
</span>
) : null}
</div>
);
}

View file

@ -9,6 +9,7 @@ export { default as DownloadClient } from "./DownloadClient";
export { default as Feed } from "./Feed";
export { default as Indexer } from "./Indexer";
export { default as Irc } from "./Irc";
export { default as Lists } from "./Lists";
export { default as Logs } from "./Logs";
export { default as Notification } from "./Notifications";
export { default as Proxy } from "./Proxy";

View file

@ -64,4 +64,9 @@ interface DownloadClient {
username: string;
password: string;
settings?: DownloadClientSettings;
}
interface ArrTag {
id: number;
label: string;
}

View file

@ -74,6 +74,7 @@ interface Filter {
max_seeders: number;
min_leechers: number;
max_leechers: number;
is_auto_updated: boolean;
actions_count: number;
actions_enabled_count: number;
actions: Action[];

44
web/src/types/List.d.ts vendored Normal file
View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2021 - 2024, Ludvig Lundgren and the autobrr contributors.
* SPDX-License-Identifier: GPL-2.0-or-later
*/
interface List {
id: number;
name: string;
enabled: boolean;
type: ListType;
client_id: number;
url: string;
headers: string[];
api_key: string;
filters: ListFilter[];
match_release: boolean;
tags_included: string[];
tags_excluded: string[];
include_unmonitored: boolean;
include_alternate_titles: boolean;
}
interface ListFilter {
id: number;
name: string;
}
interface ListCreate {
name: string;
enabled: boolean;
type: ListType;
client_id: number;
url: string;
headers: string[];
api_key: string;
filters: number[];
match_release: boolean;
tags_include: string[];
tags_exclude: string[];
include_unmonitored: boolean;
include_alternate_titles: boolean;
}
type ListType = "SONARR" | "RADARR" | "LIDARR" | "READARR" | "WHISPARR" | "MDBLIST" | "TRAKT" | "METACRITIC" | "STEAM" | "PLAINTEXT";